From b52e504f7579c3780696946682bc7408ab6c6cfb Mon Sep 17 00:00:00 2001 From: Stephen Jia Date: Mon, 2 Nov 2026 13:30:23 -0800 Subject: [PATCH 001/128] [ET-VK][conv2d] Use a 2x2x8 tile and tuned local sizes for pointwise convolution Pull Request resolved: https://github.com/pytorch/executorch/pull/24433 ## Ulterior Motive Closing the latency gap between ET-VK and LiteRT (TFLite GPU) on the DSR denoising model on mobile GPUs. The model's 80 1x1 convolutions were the largest per-operator gap on Mali. ## Rationale **What**: Reworks `conv2d_pw_tiled` around a 2x2-pixel x 8-channel tile with buffer weights, branch-free bounds handling and per-GPU local sizes. The 2x1-pixel x 4-channel tile stays for small Half grids and for Float on Mali, and Float on Mali uses texture weights. **Why**: Pointwise convolutions are load/store bound on Mali. The old tile issued 6 texel loads per 8 vec4 FMAs, read weights through a texture, and guarded every load with a bounds check. The 2x2 x 8 tile issues half as many loads per FMA. ## Details - Tile - **Before**: one tile, 2 consecutive output positions x 4 output channels per invocation. - **After**: a 2x2 output pixel block x 8 output channels (`_4x8` variants) for Float on every GPU except Mali, and for Half when the grid has at least 4096 invocations. Smaller Half grids keep the 2x1 x 4 tile, which fills the GPU better there, and so does Float on Mali. - Weight storage - **Before**: texture2d, unless the packed weights exceed texture limits. - **After**: buffer (`_bufw` variants) for Float on every GPU except Mali, and for Half when the device supports 16-bit storage buffers; texture2d otherwise. On a Mali-G715 in CI, Float buffer weights produced wrong results for padded and strided convolutions with either tile. The same cases passed with the parent's texture weights. - Bounds handling - **Before**: every input and weight load is guarded. - **After**: pixels and channel tiles past the edge read the nearest in-range address and are discarded at the store, so weight loads have no branches. One redundant K check stays on the input load; without any branch in the K loop, the Adreno compiler reads the weight buffer through a much slower path. - Local size - **Before**: 64-invocation square for every dispatch. - **After**: wide tile work groups span the channel tiles of a row of pixel tiles, with 512 threads on Mali, 128 on Adreno and 64 elsewhere, halved until they fit the device limits. The narrow tile uses 1x64 along pixel tiles on Mali when there are at least 64 of them, and the 64-invocation square otherwise. - Tests - The pointwise reference supports Half. `pw_correctness` runs Half too, and adds partial-tile shapes below and above the Half tile switch. - New timing-only `dsr_pw_set` with the DSR model's 1x1 convolution shapes. The tile switch and local sizes come from a sweep over 12 local sizes and both tiles on 57 shapes (see Test Plan). Float on Mali keeps the 2x1 x 4 tile: in the Mali offline compiler the 2x2 x 8 Float tile spills 88 bytes per thread while the 2x1 x 4 Float tile does not spill, and the wide Float tile was 2-31% slower in the sweep. ghstack-source-id: 462948223 @exported-using-ghexport Differential Revision: [D128786678](https://our.internmc.facebook.com/intern/diff/D128786678/) --- .../convolution/conv2d/conv2d_pw_tiled.glsl | 112 +++++++++++------- .../convolution/conv2d/conv2d_pw_tiled.yaml | 16 +++ .../linear_fp_packed_weight_tile_load.glslh | 22 ++++ .../ops/impl/convolution/conv2d/Conv2dPW.cpp | 103 +++++++++++++++- .../ops/benchmark/cases/conv2d/conv2d.cpp | 27 +++-- .../cases/conv2d/test_sets/pw_sets.cpp | 29 ++++- 6 files changed, 249 insertions(+), 60 deletions(-) diff --git a/backends/vulkan/runtime/graph/ops/glsl/convolution/conv2d/conv2d_pw_tiled.glsl b/backends/vulkan/runtime/graph/ops/glsl/convolution/conv2d/conv2d_pw_tiled.glsl index e4e8ae7a5ff..b703d418612 100644 --- a/backends/vulkan/runtime/graph/ops/glsl/convolution/conv2d/conv2d_pw_tiled.glsl +++ b/backends/vulkan/runtime/graph/ops/glsl/convolution/conv2d/conv2d_pw_tiled.glsl @@ -9,17 +9,24 @@ #version 450 core #define PRECISION ${PRECISION} -#define VEC4_T ${texel_load_type(DTYPE, STORAGE)} +#define T ${buffer_scalar_type(DTYPE)} +#define VEC4_T ${buffer_gvec_type(DTYPE, 4)} #define TILE_M4 ${TILE_M4} #define TILE_K4 ${TILE_K4} #define TILE_N4 ${TILE_N4} #define TILE_M ${TILE_M} +#define TILE_H ${TILE_H} +#define TILE_W (TILE_M / TILE_H) #define TILE_K ${TILE_K4 * 4} #define TILE_N ${TILE_N4 * 4} ${define_required_extensions(STORAGE, DTYPE)} +$if DTYPE == "half": + #extension GL_EXT_shader_explicit_arithmetic_types_float16 : require +$if WEIGHT_STORAGE == "buffer": + ${define_required_extensions("buffer", DTYPE)} layout(std430) buffer; @@ -27,7 +34,11 @@ layout(std430) buffer; ${layout_declare_tensor(B, "w", "t_out", DTYPE, STORAGE)} ${layout_declare_tensor(B, "r", "t_in", DTYPE, STORAGE)} -${layout_declare_tensor(B, "r", "t_weight_packed", DTYPE, "texture2d")} +${layout_declare_tensor(B, "r", "t_weight_packed", DTYPE, WEIGHT_STORAGE, is_scalar_array=False)} +$if WEIGHT_STORAGE == "buffer": + #define WEIGHT_BUFFER +$if PIXEL_TILES_ON_X: + #define PIXEL_TILES_ON_X ${layout_declare_tensor(B, "r", "t_bias", DTYPE, "texture2d")} ${layout_declare_ubo(B, "ivec4", "in_sizes")} @@ -51,46 +62,52 @@ ${layout_declare_spec_const(C, "int", "activation_type", "0")} #include "gemm/tile_utils/linear_fp_packed_weight_tile_load.glslh" #include "gemm/tile_utils/linear_fp_output_tile_fp_compute.glslh" -// Texel coordinates for the TILE_M spatial positions this invocation covers. +// Texel coordinates for the TILE_W x TILE_H block of output pixels this +// invocation covers. // -// Recovering (x, y) from the flattened spatial index costs a division and a -// modulo by W_out, which is a runtime uniform, so the compiler cannot strength -// reduce either into a shift. Those coordinates depend only on m, so computing -// them once per invocation keeps the K loop free of integer division; done -// per (m, k4) instead, a conv with K4 = 4 issues four times as many divides as -// the arithmetic they feed. +// A block spanning two rows lets each warp read and write whole cache lines of +// the block-interleaved texture layout used by some GPUs. +// +// Locating the block costs a division and a modulo by a runtime uniform, so the +// coordinates are computed once per invocation to keep the K loop free of +// integer division. struct InCoords { ivec2 pos[TILE_M]; ivec2 out_pos[TILE_M]; - bool valid[TILE_M]; + bool in_bounds[TILE_M]; + // 1 for pixels inside the input, 0 for padding. + T in_scale[TILE_M]; }; void compute_in_coords( out InCoords coords, - const int m_start, - const int M, + const int tile_idx_m, + const int tiles_x, const int W_out, - const int W_in, - const int H_in) { + const int H_out, + const ivec2 in_extents) { + const int x0 = (tile_idx_m % tiles_x) * TILE_W; + const int y0 = (tile_idx_m / tiles_x) * TILE_H; [[unroll]] for (int m = 0; m < TILE_M; ++m) { - const int out_spatial = m_start + m; - if (out_spatial >= M) { - coords.pos[m] = ivec2(0); - coords.out_pos[m] = ivec2(0); - coords.valid[m] = false; - continue; - } - const int out_x = out_spatial % W_out; - const int out_y = out_spatial / W_out; + const int x = x0 + m % TILE_W; + const int y = y0 + m / TILE_W; + coords.in_bounds[m] = x < W_out && y < H_out; + // Pixels outside the output reuse the nearest pixel inside it so the K loop + // needs no bounds check for them; their results are never stored. + const int out_x = min(x, W_out - 1); + const int out_y = min(y, H_out - 1); coords.out_pos[m] = ivec2(out_x, out_y); if (stride_1_padding_0 != 0) { coords.pos[m] = ivec2(out_x, out_y); - coords.valid[m] = true; + coords.in_scale[m] = T(1); } else { - const int in_x = out_x * stride_w - padding_w; - const int in_y = out_y * stride_h - padding_h; - coords.pos[m] = ivec2(in_x, in_y); - coords.valid[m] = in_x >= 0 && in_x < W_in && in_y >= 0 && in_y < H_in; + // Padding pixels read the nearest input pixel and are zeroed by a + // multiply. Zeroing them with a branch or select instead dropped valid + // pixels on Adreno 830 (Float) and PowerVR DXT (Half). + const ivec2 pos = + ivec2(out_x * stride_w - padding_w, out_y * stride_h - padding_h); + coords.pos[m] = clamp(pos, ivec2(0), in_extents - 1); + coords.in_scale[m] = T(all(equal(coords.pos[m], pos)) ? 1.0 : 0.0); } } } @@ -102,9 +119,15 @@ void load_input_tile_with_checks( const int K4) { [[unroll]] for (int m = 0; m < TILE_M; ++m) { [[unroll]] for (int k4 = 0; k4 < TILE_K4; ++k4) { - if (k4_start + k4 < K4 && coords.valid[m]) { + // The k4 check is redundant when TILE_K4 == 1, but with no branch left in + // the K loop the Adreno compiler reads the weight buffer through a much + // slower path. + if (k4_start + k4 < K4) { tile.data[m][k4] = - texelFetch(t_in, ivec3(coords.pos[m], k4_start + k4), 0); + VEC4_T(texelFetch(t_in, ivec3(coords.pos[m], k4_start + k4), 0)); + if (stride_1_padding_0 == 0) { + tile.data[m][k4] *= coords.in_scale[m]; + } } else { tile.data[m][k4] = VEC4_T(0.0); } @@ -116,45 +139,45 @@ void store_output_tile_with_checks( const FPOutTile out_tile, const InCoords coords, const int n4_start, - const int m_start, - const int N4, - const int M) { + const int N4) { // Reuse the coordinates computed for this invocation rather than dividing by // W_out again per output texel. [[unroll]] for (int m = 0; m < TILE_M; ++m) { [[unroll]] for (int n4 = 0; n4 < TILE_N4; ++n4) { - if (m_start + m < M && n4_start + n4 < N4) { + if (coords.in_bounds[m] && n4_start + n4 < N4) { VEC4_T texel = out_tile.data[m][n4]; if (activation_type == 1) { texel = max(texel, VEC4_T(0.0)); } else if (activation_type == 2) { texel = clamp(texel, VEC4_T(out_min), VEC4_T(out_max)); } - imageStore(t_out, ivec3(coords.out_pos[m], n4_start + n4), texel); + imageStore(t_out, ivec3(coords.out_pos[m], n4_start + n4), vec4(texel)); } } } } void main() { +#ifdef PIXEL_TILES_ON_X + const int tile_idx_m = int(gl_GlobalInvocationID.x); + const int tile_idx_n = int(gl_GlobalInvocationID.y); +#else const int tile_idx_n = int(gl_GlobalInvocationID.x); const int tile_idx_m = int(gl_GlobalInvocationID.y); +#endif const int n4_start = tile_idx_n * TILE_N4; - const int m_start = tile_idx_m * TILE_M; - const int W_in = in_sizes.x; - const int H_in = in_sizes.y; const int K = in_sizes.z; const int K4 = div_up_4(K); const int W_out = out_sizes.x; const int H_out = out_sizes.y; - const int M = W_out * H_out; + const int tiles_x = div_up(W_out, TILE_W); const int N = out_sizes.z; const int N4 = div_up_4(N); - if (n4_start >= N4 || m_start >= M) { + if (n4_start >= N4 || tile_idx_m >= tiles_x * div_up(H_out, TILE_H)) { return; } @@ -165,11 +188,12 @@ void main() { FPWeightTile w_tile; InCoords in_coords; - compute_in_coords(in_coords, m_start, M, W_out, W_in, H_in); + compute_in_coords( + in_coords, tile_idx_m, tiles_x, W_out, H_out, in_sizes.xy); for (int k4 = 0; k4 < K4; k4++) { load_input_tile_with_checks(in_tile, in_coords, k4, K4); - load_packed_weight_tile_with_checks(w_tile, n4_start, k4, 0, N4, K4); + load_packed_weight_tile_clamped(w_tile, n4_start, k4, 0, N4, K4); fp_accumulate_with_fp_weight(out_tile, in_tile, w_tile); } @@ -178,10 +202,10 @@ void main() { [[unroll]] for (int n4 = 0; n4 < TILE_N4; ++n4) { if (n4_start + n4 < N4) { out_tile.data[m][n4] += - texelFetch(t_bias, ivec2(n4_start + n4, 0), 0); + VEC4_T(texelFetch(t_bias, ivec2(n4_start + n4, 0), 0)); } } } - store_output_tile_with_checks(out_tile, in_coords, n4_start, m_start, N4, M); + store_output_tile_with_checks(out_tile, in_coords, n4_start, N4); } diff --git a/backends/vulkan/runtime/graph/ops/glsl/convolution/conv2d/conv2d_pw_tiled.yaml b/backends/vulkan/runtime/graph/ops/glsl/convolution/conv2d/conv2d_pw_tiled.yaml index 82a6dcd24e2..80299851040 100644 --- a/backends/vulkan/runtime/graph/ops/glsl/convolution/conv2d/conv2d_pw_tiled.yaml +++ b/backends/vulkan/runtime/graph/ops/glsl/convolution/conv2d/conv2d_pw_tiled.yaml @@ -8,13 +8,29 @@ conv2d_pw_tiled: parameter_names_with_default_values: DTYPE: float STORAGE: texture3d + WEIGHT_STORAGE: texture2d TILE_M4: 1 TILE_K4: 1 TILE_N4: 1 TILE_M: 2 + TILE_H: 1 + PIXEL_TILES_ON_X: false generate_variant_forall: DTYPE: - VALUE: float - VALUE: half shader_variants: - NAME: conv2d_pw_tiled + - NAME: conv2d_pw_tiled_bufw + WEIGHT_STORAGE: buffer + - NAME: conv2d_pw_tiled_4x8 + TILE_M: 4 + TILE_H: 2 + TILE_N4: 2 + PIXEL_TILES_ON_X: true + - NAME: conv2d_pw_tiled_4x8_bufw + TILE_M: 4 + TILE_H: 2 + TILE_N4: 2 + WEIGHT_STORAGE: buffer + PIXEL_TILES_ON_X: true diff --git a/backends/vulkan/runtime/graph/ops/glsl/gemm/tile_utils/linear_fp_packed_weight_tile_load.glslh b/backends/vulkan/runtime/graph/ops/glsl/gemm/tile_utils/linear_fp_packed_weight_tile_load.glslh index 5592042f6f7..9011569e005 100644 --- a/backends/vulkan/runtime/graph/ops/glsl/gemm/tile_utils/linear_fp_packed_weight_tile_load.glslh +++ b/backends/vulkan/runtime/graph/ops/glsl/gemm/tile_utils/linear_fp_packed_weight_tile_load.glslh @@ -72,4 +72,26 @@ void load_packed_weight_tile_with_checks( } } +// Out-of-range blocks load the nearest in-range block instead of zeros, which +// keeps branches out of the load. Use only when the results computed from those +// blocks are discarded or multiplied by zero inputs. +void load_packed_weight_tile_clamped( + out FPWeightTile tile, + const int n4_start, + const int k4_start, + const int b, + const int N4, + const int K4) { + [[unroll]] for (int dk4 = 0; dk4 < TILE_K4; dk4++) { + const int k4 = TILE_K4 == 1 ? k4_start : min(k4_start + dk4, K4 - 1); + [[unroll]] for (int dk = 0; dk < 4; dk++) { + const int k = dk4 * 4 + dk; + [[unroll]] for (int n4 = 0; n4 < TILE_N4; n4++) { + tile.data[k][n4] = load_packed_weight_x4( + min(n4_start + n4, N4 - 1), dk, k4, b, K4, N4); + } + } + } +} + #endif // LINEAR_FP_PACKED_WEIGHT_TILE_LOAD_GLSLH diff --git a/backends/vulkan/runtime/graph/ops/impl/convolution/conv2d/Conv2dPW.cpp b/backends/vulkan/runtime/graph/ops/impl/convolution/conv2d/Conv2dPW.cpp index 05853f86a6c..81f624a891a 100644 --- a/backends/vulkan/runtime/graph/ops/impl/convolution/conv2d/Conv2dPW.cpp +++ b/backends/vulkan/runtime/graph/ops/impl/convolution/conv2d/Conv2dPW.cpp @@ -20,6 +20,34 @@ namespace vkcompute { // Shader dispatch utilities // +namespace { + +// Below this many wide tile invocations the GPU is underfilled, and Half +// convolutions are faster with the narrow tile. The threshold sits between the +// largest swept Half grid that was faster with the narrow tile on most GPUs +// (2560 wide tile invocations) and the smallest faster with the wide tile on +// every GPU (12288); grids in between were not swept. +constexpr uint64_t kMinWideTileInvocations = 4096; + +/* + * The 2x2-pixel x 8-channel tile issues half as many input and weight loads per + * FMA as the 2x1-pixel x 4-channel tile. Float uses it on every GPU except + * Mali, where the wide Float tile spills registers in the Mali offline compiler + * and was 2-31% slower than the narrow tile. + */ +bool use_wide_pw_tile(ComputeGraph* graph, const ValueRef out) { + if (graph->dtype_of(out) != vkapi::kHalf) { + return !graph->device_is_mali(); + } + const uint64_t M = uint64_t(graph->size_at(-1, out)) * + graph->size_at(-2, out); + const uint64_t N4 = utils::div_up_4(graph->size_at(-3, out)); + return utils::div_up(M, uint64_t(4)) * utils::div_up(N4, uint64_t(2)) >= + kMinWideTileInvocations; +} + +} // namespace + void resize_conv2d_pw_tiled_node( ComputeGraph* graph, const std::vector& args, @@ -60,9 +88,16 @@ vkapi::ShaderInfo pick_conv2d_pw_tiled_shader( const std::vector& resize_args) { (void)resize_args; const ValueRef out = args.at(0).refs.at(0); + const ValueRef packed_weight = args.at(1).refs.at(1); std::string kernel_name = "conv2d_pw_tiled"; kernel_name.reserve(kShaderNameReserve); + if (use_wide_pw_tile(graph, out)) { + kernel_name += "_4x8"; + } + if (graph->is_buffer_storage(packed_weight)) { + kernel_name += "_bufw"; + } add_dtype_suffix(kernel_name, graph->dtype_of(out)); return VK_KERNEL_FROM_STR(kernel_name); } @@ -78,10 +113,64 @@ GlobalWorkGrid pick_conv2d_pw_tiled_gwg( uint32_t W = graph->size_at(-1, out); uint32_t H = graph->size_at(-2, out); uint32_t C_out = graph->size_at(-3, out); - uint32_t M = H * W; uint32_t N4 = utils::div_up_4(C_out); - // TILE_N4=1, TILE_M=2. Must match conv2d_pw_tiled.yaml. - return GlobalWorkGrid({N4, utils::div_up(M, 2u), 1u}, kTiledWorkGrid); + // Tile sizes must match conv2d_pw_tiled.yaml. + const bool wide = use_wide_pw_tile(graph, out); + const uint32_t tile_n4 = wide ? 2u : 1u; + const uint32_t tile_w = 2u; + const uint32_t tile_h = wide ? 2u : 1u; + const uint32_t n_tiles = utils::div_up(N4, tile_n4); + const uint32_t m_tiles = utils::div_up(W, tile_w) * utils::div_up(H, tile_h); + // The wide tile variant puts pixel tiles on x. + if (wide) { + return GlobalWorkGrid({m_tiles, n_tiles, 1u}, kTiledWorkGrid); + } + return GlobalWorkGrid({n_tiles, m_tiles, 1u}, kTiledWorkGrid); +} + +/* + * Wide tile work groups span the channel tiles of a row of pixel tiles, so the + * input they share is read through one core's cache: y covers the channel tiles + * (rounded up to a power of two) while x keeps at least min_x pixel tiles, and + * x takes the remaining threads. + * + * The thread count and min_x per GPU family come from sweeping 12 local sizes + * (32 to 512 threads with min_x 4 to 32, and an 8x8 square) over 57 Float and + * Half 1x1 convolutions with 7x7 to 1008x756 outputs on Mali, Adreno and + * Xclipse phones. On the large convolutions, Mali was 5-8% faster with 512 + * threads than with 128, Adreno was 1-10% slower with 512 than with 128, and + * Xclipse was 30% slower with 128 than with 64. + */ +LocalWorkGroup pick_conv2d_pw_tiled_lwg( + ComputeGraph* graph, + const vkapi::ShaderInfo& shader, + const GlobalWorkGrid& gwg, + const std::vector& args, + const std::vector& resize_args) { + if (use_wide_pw_tile(graph, args.at(0).refs.at(0))) { + const bool mali = graph->device_is_mali(); + const bool adreno = graph->device_is_adreno(); + uint32_t nthreads = mali ? 512u : adreno ? 128u : 64u; + const uint32_t min_x = mali || adreno ? 16u : 4u; + const vkapi::Adapter* const adapter = graph->context()->adapter_ptr(); + while (nthreads > adapter->max_compute_workgroup_invocations() || + nthreads > adapter->max_compute_workgroup_size()[0]) { + nthreads /= 2u; + } + // y stays at or below nthreads / min_x (32 at most), within the 128 that + // Vulkan guarantees for the second work group dimension. + uint32_t y = 1u; + while (y < gwg[1] && y * min_x < nthreads) { + y *= 2u; + } + return LocalWorkGroup(nthreads / y, y, 1u); + } + // On Mali, a work group of pixel tiles that share one channel tile lets each + // warp read a single weight stream instead of one per channel tile. + if (graph->device_is_mali() && gwg[1] >= 64u) { + return LocalWorkGroup(1u, 64u, 1u); + } + return pick_xy_square_lwg(graph, shader, gwg, args, resize_args); } // @@ -107,9 +196,13 @@ ValueRef prepack_conv2d_pw_weight( const int64_t output_height = K4; const int64_t output_width = N4 * 4 * 4; + // Buffer weights are faster than texture weights on every GPU measured. + // Half buffers need 16-bit storage support. utils::StorageType weight_storage = utils::kTexture2D; uint32_t max_extent = graph.context()->adapter_ptr()->max_texture2d_dim(); - if (output_width / 4 > max_extent || + const bool buffer_weights = graph.dtype_of(weight_data) != vkapi::kHalf || + graph.float16_buffers_enabled(); + if (buffer_weights || output_width / 4 > max_extent || static_cast(output_height) > max_extent) { weight_storage = utils::kBuffer; } @@ -191,7 +284,7 @@ void add_conv2d_pw_tiled_node( graph, pick_conv2d_pw_tiled_shader, pick_conv2d_pw_tiled_gwg, - pick_xy_square_lwg, + pick_conv2d_pw_tiled_lwg, // Inputs and Outputs {{out, vkapi::kWrite}, {{in, packed_weight, packed_bias}, vkapi::kRead}}, // Shader params buffers diff --git a/backends/vulkan/test/ops/benchmark/cases/conv2d/conv2d.cpp b/backends/vulkan/test/ops/benchmark/cases/conv2d/conv2d.cpp index 20b945e9346..3d40d484833 100644 --- a/backends/vulkan/test/ops/benchmark/cases/conv2d/conv2d.cpp +++ b/backends/vulkan/test/ops/benchmark/cases/conv2d/conv2d.cpp @@ -645,8 +645,8 @@ void conv2d_pw_reference_impl(TestCase& test_case) { const ValueSpec& bias_spec = test_case.inputs()[2]; ValueSpec& output = test_case.outputs()[0]; - if (input.dtype != vkapi::kFloat) { - throw std::invalid_argument("Reference only supports float"); + if (input.dtype != vkapi::kFloat && input.dtype != vkapi::kHalf) { + throw std::invalid_argument("Reference only supports float and half"); } auto input_sizes = input.get_tensor_sizes(); @@ -658,8 +658,18 @@ void conv2d_pw_reference_impl(TestCase& test_case) { int64_t W = input_sizes[3]; int64_t C_out = weight_sizes[0]; - auto& input_data = input.get_float_data(); - auto& weight_data = weight.get_float_data(); + // Mirrors create_conv2d_pw_test_case's PERF classification: large-K half + // shapes stay timing-only, since FP16 accumulation error would exceed the + // half tolerance. + if (input.dtype == vkapi::kHalf && + (C_in > kRefDimSizeLimit || C_out > kRefDimSizeLimit || + H > kRefDimSizeLimit || W > kRefDimSizeLimit)) { + throw std::invalid_argument( + "Half reference skipped for PERF shape (timing-only)"); + } + + // get_element() materializes a float regardless of dtype, so the same loop + // body serves both FP32 and FP16. auto& ref_data = output.get_ref_float_data(); ref_data.resize(N * C_out * H * W, 0.0f); @@ -669,15 +679,14 @@ void conv2d_pw_reference_impl(TestCase& test_case) { for (int64_t w = 0; w < W; ++w) { float sum = 0.0f; for (int64_t ci = 0; ci < C_in; ++ci) { - float in_val = - input_data[n * (C_in * H * W) + ci * (H * W) + h * W + w]; + float in_val = input.get_element( + n * (C_in * H * W) + ci * (H * W) + h * W + w); // weight is [C_out, C_in, 1, 1] - float w_val = weight_data[co * C_in + ci]; + float w_val = weight.get_element(co * C_in + ci); sum += in_val * w_val; } if (!bias_spec.is_none()) { - auto& bias_data = bias_spec.get_float_data(); - sum += bias_data[co]; + sum += bias_spec.get_element(co); } ref_data[n * (C_out * H * W) + co * (H * W) + h * W + w] = sum; } diff --git a/backends/vulkan/test/ops/benchmark/cases/conv2d/test_sets/pw_sets.cpp b/backends/vulkan/test/ops/benchmark/cases/conv2d/test_sets/pw_sets.cpp index afb206ea6e2..d71ef21ba96 100644 --- a/backends/vulkan/test/ops/benchmark/cases/conv2d/test_sets/pw_sets.cpp +++ b/backends/vulkan/test/ops/benchmark/cases/conv2d/test_sets/pw_sets.cpp @@ -47,11 +47,14 @@ REGISTER_TEST_CASE_SET("conv2d", "pw_correctness") { // Non-multiple-of-4 channels {1, 13, 27, 8, 8, false}, {1, 33, 17, 8, 8, false}, + // Partial pixel and channel tiles, below and above the invocation count + // where Half switches to the wide tile + {1, 33, 52, 41, 47, true}, + {1, 33, 58, 63, 61, true}, }; const std::vector groups = { - // Accuracy test cases (float only) - {accuracy_configs, {vkapi::kFloat}}, + {accuracy_configs, {vkapi::kFloat, vkapi::kHalf}}, }; return { generate_pw_test_cases(groups), @@ -98,6 +101,28 @@ REGISTER_TEST_CASE_SET("conv2d", "pw_performance") { conv2d_pw_flop_calculator}; } +REGISTER_TEST_CASE_SET("conv2d", "dsr_pw_set") { + // DSR dim=64 1x1 convs at the 1512x2016 input resolution. + const std::vector configs = { + {1, 64, 64, 756, 1008, true}, + {1, 32, 128, 756, 1008, true}, + {1, 128, 32, 756, 1008, true}, + {1, 128, 128, 378, 504, true}, + {1, 64, 256, 378, 504, true}, + {1, 256, 64, 378, 504, true}, + {1, 256, 256, 189, 252, true}, + {1, 128, 512, 189, 252, true}, + {1, 512, 128, 189, 252, true}, + }; + // Full-resolution FP16 configurations are timing-only. + return { + generate_pw_test_cases({{configs, {vkapi::kHalf}}}), + nullptr, + conv2d_pw_flop_calculator, + 5, + 20}; +} + } // namespace conv2d } // namespace prototyping } // namespace vulkan From b6f80e3e086b07af32ddf350c0f7177e8f6b548b Mon Sep 17 00:00:00 2001 From: shewu-quic <138087975+shewu-quic@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:18:05 +0800 Subject: [PATCH 002/128] Qualcomm AI Engine Direct - Support QAT for concat observer (#23347) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Summary See [PR #23299](https://github.com/pytorch/executorch/pull/23299) for the root-cause writeup — `ConcatObserver` is PTQ-only (plain observer, pokes other observers' `min_val`/`max_val` directly), which breaks under QAT since the output never gets fake-quantized and the write lands on a dead attribute on `FusedMovingAvgObsFakeQuantize` inputs. This PR takes a different fix than #23299's `SharedQuantizationSpec` approach: that shares qparams only with the first cat input, which clips any other input with a wider range (verified with a `cat([x+1, y*4])` toy model — the first input's calibrated range doesn't cover the second). Instead, `concat_observer_ctr()` keeps `ConcatObserver` unchanged for PTQ, and adds `ConcatFakeQuantize` for QAT — a `FakeQuantize` paired with `ConcatObserver`, same pattern as the existing `PerBlockParamObserver`/`PerBlockParamFakeQuantize` — so the output is still fake-quantized while `ConcatObserver` keeps aggregating the true max range across every input and the output, not just the first. ### Test plan Added to `backends/qualcomm/tests/rework/utils/test.py` (no device required): - `test_qat_concat` — QAT-prepared `cat([x+1, y*4])`: output is `ConcatFakeQuantize` wrapping `ConcatObserver`, its observed range covers both inputs within one quant step, and `convert_pt2e` gives inputs and output one shared qparam. - `test_concat_observer_deepcopy` — cat at the end of a 300-node chain: `copy.deepcopy` + `convert_pt2e` doesn't raise `RecursionError`, and the copy's `input_observers` point at the copy's own submodules. --- .../quantizer/annotators/htp_rules.py | 13 +- .../quantizer/annotators/lpai_rules.py | 13 +- .../quantizer/observers/concat_observer.py | 89 +++++- backends/qualcomm/tests/rework/src/utils.py | 260 +++++++++++++++++- backends/qualcomm/tests/rework/utils/test.py | 12 + 5 files changed, 345 insertions(+), 42 deletions(-) diff --git a/backends/qualcomm/quantizer/annotators/htp_rules.py b/backends/qualcomm/quantizer/annotators/htp_rules.py index e12f354def8..b32252af544 100644 --- a/backends/qualcomm/quantizer/annotators/htp_rules.py +++ b/backends/qualcomm/quantizer/annotators/htp_rules.py @@ -17,7 +17,7 @@ import torch from executorch.backends.qualcomm.quantizer.observers.concat_observer import ( - ConcatObserver, + concat_observer_ctr, ) from executorch.backends.qualcomm.quantizer.qconfig import ( get_16a16w_qnn_ptq_config, @@ -255,14 +255,13 @@ def annotate(node: Node, quantization_config: QuantizationConfig) -> None: qscheme=quantization_config.output_activation.qscheme, quant_max=quantization_config.output_activation.quant_max, quant_min=quantization_config.output_activation.quant_min, - observer_or_fake_quant_ctr=ConcatObserver.with_args( + observer_or_fake_quant_ctr=concat_observer_ctr( # we need to know the concat node in order to hack all the input observers' data range # since deep copy of fake tensor (node.meta["val"]) is inhibited - # we could only ship grap & node name and perform postprocess inside observer currently - **{ - "node_name": node.name, - "graph": node.graph, - } + # we could only ship graph & node name and perform postprocess inside observer currently + node.name, + node.graph, + quantization_config.output_activation, ), ) node.meta[Q_ANNOTATION_KEY] = QuantizationAnnotation( diff --git a/backends/qualcomm/quantizer/annotators/lpai_rules.py b/backends/qualcomm/quantizer/annotators/lpai_rules.py index 8b30c9427a9..bea0eb39689 100644 --- a/backends/qualcomm/quantizer/annotators/lpai_rules.py +++ b/backends/qualcomm/quantizer/annotators/lpai_rules.py @@ -13,7 +13,7 @@ import torch from executorch.backends.qualcomm.quantizer.observers.concat_observer import ( - ConcatObserver, + concat_observer_ctr, ) from executorch.backends.qualcomm.quantizer.qconfig import ( get_16a16w_qnn_ptq_config, @@ -197,14 +197,13 @@ def annotate(node: Node, quantization_config: QuantizationConfig) -> None: qscheme=quantization_config.output_activation.qscheme, quant_max=quantization_config.output_activation.quant_max, quant_min=quantization_config.output_activation.quant_min, - observer_or_fake_quant_ctr=ConcatObserver.with_args( + observer_or_fake_quant_ctr=concat_observer_ctr( # we need to know the concat node in order to hack all the input observers' data range # since deep copy of fake tensor (node.meta["val"]) is inhibited - # we could only ship grap & node name and perform postprocess inside observer currently - **{ - "node_name": node.name, - "graph": node.graph, - } + # we could only ship graph & node name and perform postprocess inside observer currently + node.name, + node.graph, + quantization_config.output_activation, ), ) node.meta[Q_ANNOTATION_KEY] = QuantizationAnnotation( diff --git a/backends/qualcomm/quantizer/observers/concat_observer.py b/backends/qualcomm/quantizer/observers/concat_observer.py index d52018531c5..2e1447d786a 100644 --- a/backends/qualcomm/quantizer/observers/concat_observer.py +++ b/backends/qualcomm/quantizer/observers/concat_observer.py @@ -4,9 +4,16 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. +import copy + import torch from executorch.backends.qualcomm.utils.constants import DEFAULT_EPS_FP32 -from torchao.quantization.pt2e import UniformQuantizationObserverBase +from torchao.quantization.pt2e import ( + FakeQuantize, + FakeQuantizeBase, + UniformQuantizationObserverBase, +) +from torchao.quantization.pt2e.observer import PartialWrapper class ConcatObserver(UniformQuantizationObserverBase): @@ -48,6 +55,33 @@ def __init__( self.input_nodes = self.concat_node.args[0] self.input_observers = [] + def __deepcopy__(self, memo): + # input_nodes still point at the original owning_module, + # so a lazy resolve on the copy would bind to the original's observers. + if len(self.input_observers) == 0: + self._collect_input_observers() + + # Share by reference: deep-copying fx.Nodes recurses the whole graph. + new = type(self).__new__(type(self)) + memo[id(self)] = new + for key, value in self.__dict__.items(): + new.__dict__[key] = ( + value + if key in ("concat_node", "input_nodes") + else copy.deepcopy(value, memo) + ) + return new + + def _collect_input_observers(self): + # we cannot do this in constructor since observers have not appeared + for node in self.input_nodes: + obs_node = list( + filter(lambda user: user != self.concat_node, node.users.keys()) + )[0] + self.input_observers.append( + getattr(obs_node.graph.owning_module, obs_node.name) + ) + def forward(self, x_orig): # calculate the min / max first min_val, max_val = torch.aminmax(x_orig.detach()) @@ -56,21 +90,48 @@ def forward(self, x_orig): if len(self.input_observers) == 0: # collect observers first if they are not cached - # we cannot do this in constructor since observers have not appeared - for node in self.input_nodes: - obs_node = list( - filter(lambda user: user != self.concat_node, node.users.keys()) - )[0] - self.input_observers.append( - getattr(obs_node.graph.owning_module, obs_node.name) - ) - - # update min / max for all observers of input nodes - for observers in self.input_observers: - observers.min_val = self.min_val - observers.max_val = self.max_val + self._collect_input_observers() + + # push min/max to each input's observer. Under QAT it's wrapped in a + # FakeQuantize, whose range lives on activation_post_process. + for observer in self.input_observers: + target = ( + observer.activation_post_process + if isinstance(observer, FakeQuantizeBase) + else observer + ) + # a fixed-qparams input has no min_val/max_val + if not hasattr(target, "min_val") or not hasattr(target, "max_val"): + continue + target.min_val.copy_(self.min_val) + target.max_val.copy_(self.max_val) return x_orig def calculate_qparams(self): return self._calculate_qparams(self.min_val, self.max_val) + + +class ConcatFakeQuantize(FakeQuantize): + """FakeQuantize using ConcatObserver, so concat's output is fake-quantized + under QAT instead of just carrying a (never simulated) observer.""" + + def __init__(self, node_name, graph, **kwargs): + super().__init__( + observer=ConcatObserver, node_name=node_name, graph=graph, **kwargs + ) + + +def concat_observer_ctr(node_name, graph, output_activation): + """ + PTQ keeps using ConcatObserver; QAT swaps to ConcatFakeQuantize so the + output stays fake-quantized, but both share the same max-range behavior. + """ + kwargs = {"node_name": node_name, "graph": graph} + # with_args() can be chained, so unwrap until we hit the class. + unwrapped = output_activation.observer_or_fake_quant_ctr + while isinstance(unwrapped, PartialWrapper): + unwrapped = unwrapped.p.func + if isinstance(unwrapped, type) and issubclass(unwrapped, FakeQuantizeBase): + return ConcatFakeQuantize.with_args(**kwargs) + return ConcatObserver.with_args(**kwargs) diff --git a/backends/qualcomm/tests/rework/src/utils.py b/backends/qualcomm/tests/rework/src/utils.py index fb028708c40..9b564f83a7c 100644 --- a/backends/qualcomm/tests/rework/src/utils.py +++ b/backends/qualcomm/tests/rework/src/utils.py @@ -4,6 +4,7 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. +import copy import logging import operator import os @@ -710,15 +711,15 @@ def calibrator(gm): class QAT: @staticmethod def _make_qat_quantizer(quant_dtype, block_size_map=None): - q = make_quantizer( + quantizer = make_quantizer( quant_dtype=quant_dtype, per_channel_conv=True, is_qat=True, soc_model="SM8650", ) if block_size_map: - q.set_block_size_map(block_size_map) - return q + quantizer.set_block_size_map(block_size_map) + return quantizer @staticmethod def _get_converted_module( @@ -743,7 +744,10 @@ def test(subtests): from executorch.backends.qualcomm.quantizer.observers.per_block_param_observer import ( PerBlockParamFakeQuantize, ) - from torchao.quantization.pt2e import FusedMovingAvgObsFakeQuantize + from torchao.quantization.pt2e import ( + FakeQuantizeBase, + FusedMovingAvgObsFakeQuantize, + ) module = _UtilsModel() inputs = (torch.randn(1, 4, 8, 8), torch.randn(1, 4, 8, 8)) @@ -751,15 +755,13 @@ def test(subtests): with subtests.test(msg="16a4w_pcq"): # activation: FusedMovingAvgObsFakeQuantize (uint16 range, per-tensor) # weight: FusedMovingAvgObsFakeQuantize (int4 range [-7,7], per-channel) - q = __class__._make_qat_quantizer(QuantDtype.use_16a4w) + quantizer = __class__._make_qat_quantizer(QuantDtype.use_16a4w) exported = torch.export.export(module, inputs, strict=True).module() - prepared = prepare_qat_pt2e(exported, q) + prepared = prepare_qat_pt2e(exported, quantizer) fq_modules = [ - m - for m in prepared.modules() - if isinstance(m, FusedMovingAvgObsFakeQuantize) + m for m in prepared.modules() if isinstance(m, FakeQuantizeBase) ] - assert len(fq_modules) > 0, "no FusedMovingAvgObsFakeQuantize found" + assert len(fq_modules) > 0, "no FakeQuantizeBase found" # activation FQs have uint16 range (quant_max=65535) act_fqs = [m for m in fq_modules if m.quant_max == 65535] # weight FQs have int4 range (quant_max=7) @@ -772,12 +774,12 @@ def test(subtests): with subtests.test(msg="16a4w_block"): # activation: FusedMovingAvgObsFakeQuantize (uint16 range) # weight: PerBlockParamFakeQuantize (int4 range) - q = __class__._make_qat_quantizer( + quantizer = __class__._make_qat_quantizer( QuantDtype.use_16a4w_block, block_size_map={"conv2d": (1, 4, 1, 1)}, ) exported = torch.export.export(module, inputs, strict=True).module() - prepared = prepare_qat_pt2e(exported, q) + prepared = prepare_qat_pt2e(exported, quantizer) act_fqs = [ m for m in prepared.modules() @@ -800,9 +802,9 @@ def test(subtests): with subtests.test(msg="fp16a8w"): # activation: None (FP16 — no activation quantization) # weight: FusedMovingAvgObsFakeQuantize (int8 range [-127,127], per-tensor or per-channel) - q = __class__._make_qat_quantizer(QuantDtype.use_fp16a8w) + quantizer = __class__._make_qat_quantizer(QuantDtype.use_fp16a8w) exported = torch.export.export(module, inputs, strict=True).module() - prepared = prepare_qat_pt2e(exported, q) + prepared = prepare_qat_pt2e(exported, quantizer) fq_modules = [ m for m in prepared.modules() @@ -818,6 +820,236 @@ def test(subtests): __class__._get_converted_module(module, prepared, inputs) +class ConcatObserverDeepcopy: + """copy.deepcopy of a prepared model, as done before convert_pt2e by + training frameworks, must not walk the graph through ConcatObserver's + fx.Node references. + """ + + class _DeepCat(torch.nn.Module): + def __init__(self, depth: int = 300): + super().__init__() + self.depth = depth + + def forward(self, x, y): + for _ in range(self.depth): + x = x + 0.5 + return torch.cat([x, y], dim=1) + + @staticmethod + def test(subtests): + from executorch.backends.qualcomm.quantizer.observers.concat_observer import ( + ConcatObserver, + ) + + module = __class__._DeepCat() + inputs = (torch.randn(1, 4, 8), torch.randn(1, 4, 8)) + quantizer = make_quantizer(quant_dtype=QuantDtype.use_8a8w, soc_model="SM8650") + exported = torch.export.export(module, inputs, strict=True).module() + prepared = prepare_pt2e(exported, quantizer) + prepared(*inputs) + + with subtests.test(msg="deepcopy_and_convert"): + copied = copy.deepcopy(prepared) + convert_pt2e(copied) + + with subtests.test(msg="copy_input_observers_point_into_the_copy"): + [(name, observer)] = [ + (n, m) + for n, m in prepared.named_modules() + if isinstance(m, ConcatObserver) + ] + assert len(observer.input_observers) == 2 + names = {id(m): n for n, m in prepared.named_modules() if n} + input_names = [names[id(obs)] for obs in observer.input_observers] + + copied = copy.deepcopy(prepared) + copied_observer = copied.get_submodule(name) + for input_name, copied_input in zip( + input_names, copied_observer.input_observers + ): + assert copied_input is copied.get_submodule(input_name) + assert copied_observer.concat_node is observer.concat_node + torch.testing.assert_close(copied_observer.min_val, observer.min_val) + torch.testing.assert_close(copied_observer.max_val, observer.max_val) + + with subtests.test( + msg="deepcopy_before_first_forward_does_not_leak_into_original" + ): + fresh_module = __class__._DeepCat() + fresh_exported = torch.export.export( + fresh_module, inputs, strict=True + ).module() + fresh_prepared = prepare_pt2e(fresh_exported, quantizer) + + # deepcopy before input_observers has ever been lazily populated + fresh_copy = copy.deepcopy(fresh_prepared) + fresh_copy(*inputs) + + for n, m in fresh_prepared.named_modules(): + if hasattr(m, "min_val"): + assert m.min_val == float("inf") and m.max_val == float( + "-inf" + ), f"original model's {n} was mutated by calibrating its copy" + + +class QATConcat: + """Multiple QAT steps with per-step shifting input ranges.""" + + class _ConcatQatModel(torch.nn.Module): + def forward(self, x, y): + return torch.cat([x, y], dim=1) + + @staticmethod + def test(subtests): + module = __class__._ConcatQatModel() + # step 1 sets a wide baseline, step 2 is narrower than history + # (shared range must not shrink), step 3 widens again on the + # *other* input (union must keep growing on either side). + steps = [ + (torch.ones(1, 4, 8) * 1.0, torch.ones(1, 4, 8) * 4.0), + (torch.ones(1, 4, 8) * 0.1, torch.ones(1, 4, 8) * 0.1), + (torch.ones(1, 4, 8) * 8.0, torch.ones(1, 4, 8) * 0.5), + ] + + quantizer = QAT._make_qat_quantizer(QuantDtype.use_8a8w) + exported = torch.export.export(module, steps[0], strict=True).module() + prepared = prepare_qat_pt2e(exported, quantizer) + + [cat] = [ + n for n in prepared.graph.nodes if n.target == torch.ops.aten.cat.default + ] + output_module = prepared.get_submodule(list(cat.users)[0].target) + input_modules = [prepared.get_submodule(n.target) for n in cat.args[0]] + concat_observer = output_module.activation_post_process + + running_min, running_max = float("inf"), float("-inf") + prev_shared_min, prev_shared_max = float("inf"), float("-inf") + prior_running_min, prior_running_max = running_min, running_max + for step_idx, (x, y) in enumerate(steps): + prepared(x, y) + running_min = min(running_min, x.min().item(), y.min().item()) + running_max = max(running_max, x.max().item(), y.max().item()) + + with subtests.test(msg=f"step_{step_idx}_shared_range_never_shrinks"): + assert concat_observer.min_val.item() <= prev_shared_min + assert concat_observer.max_val.item() >= prev_shared_max + + if step_idx > 0: + with subtests.test( + msg=f"step_{step_idx}_shared_range_covers_prior_union" + ): + # EMA observers need a step to settle after a sudden + # widen, so only require the shared range to have + # caught up with the union as of the *previous* step, + # not this step's own fresh extremes. + quant_step = (prior_running_max - prior_running_min) / 255 + assert ( + concat_observer.min_val.item() <= prior_running_min + quant_step + ) + assert ( + concat_observer.max_val.item() >= prior_running_max - quant_step + ) + + prev_shared_min = concat_observer.min_val.item() + prev_shared_max = concat_observer.max_val.item() + prior_running_min, prior_running_max = running_min, running_max + + with subtests.test(msg=f"step_{step_idx}_every_input_exactly_synced"): + for input_module in input_modules: + target = input_module.activation_post_process + torch.testing.assert_close(target.min_val, concat_observer.min_val) + torch.testing.assert_close(target.max_val, concat_observer.max_val) + + with subtests.test(msg="convert_after_multi_step_gives_one_qparam"): + converted = convert_pt2e(prepared) + [cat] = [ + n + for n in converted.graph.nodes + if n.target == torch.ops.aten.cat.default + ] + cat_users = list(cat.users) + assert ( + len(cat_users) == 1 + ), f"Expected 1 user of cat, got {len(cat_users)}: {cat_users}" + dequant = cat_users[0] + qparam_nodes = list(cat.args[0]) + [dequant] + qparams = {tuple(n.args[1:3]) for n in qparam_nodes} + assert len(qparams) == 1, qparams + + +class QATConcatWithFixedQParamsInputs: + """A cat() input coming straight from a fixed-qparams op (sigmoid) has + no min_val/max_val of its own: ConcatObserver's range-sharing hack must + skip it instead of stomping its fixed scale/zero_point, while the + regular input still gets synced to the shared range as usual. + """ + + class _ConcatSigmoidModel(torch.nn.Module): + def forward(self, x, y): + return torch.cat([torch.sigmoid(x), y], dim=1) + + @staticmethod + def test(subtests): + from torchao.quantization.pt2e import FixedQParamsObserver + + module = __class__._ConcatSigmoidModel() + # y's range is far outside sigmoid's [0, 1] output, so the shared + # concat range the regular input gets synced to looks nothing like + # the fixed input's own qparams. + inputs = (torch.randn(1, 4, 8), torch.randn(1, 4, 8) * 5) + + quantizer = QAT._make_qat_quantizer(QuantDtype.use_8a8w) + exported = torch.export.export(module, inputs, strict=True).module() + prepared = prepare_qat_pt2e(exported, quantizer) + + [cat] = [ + n for n in prepared.graph.nodes if n.target == torch.ops.aten.cat.default + ] + input_modules = [prepared.get_submodule(n.target) for n in cat.args[0]] + fixed_idx = next( + i + for i, m in enumerate(input_modules) + if isinstance(m, FixedQParamsObserver) + ) + fixed_module = input_modules[fixed_idx] + regular_module = input_modules[1 - fixed_idx] + concat_observer = prepared.get_submodule( + list(cat.users)[0].target + ).activation_post_process + initial_scale, initial_zero_point = fixed_module.calculate_qparams() + + with subtests.test(msg="no_crash_with_fixed_qparams_input"): + for _ in range(3): + prepared(*inputs) + + with subtests.test(msg="fixed_input_qparams_untouched_by_shared_range"): + scale, zero_point = fixed_module.calculate_qparams() + torch.testing.assert_close(scale, initial_scale) + torch.testing.assert_close(zero_point, initial_zero_point) + + with subtests.test(msg="regular_input_still_synced_to_shared_range"): + target = regular_module.activation_post_process + torch.testing.assert_close(target.min_val, concat_observer.min_val) + torch.testing.assert_close(target.max_val, concat_observer.max_val) + + with subtests.test(msg="convert_requantizes_only_the_fixed_input"): + converted = convert_pt2e(prepared) + [cat] = [ + n + for n in converted.graph.nodes + if n.target == torch.ops.aten.cat.default + ] + [dequant] = list(cat.users) + fixed_arg = cat.args[0][fixed_idx] + regular_arg = cat.args[0][1 - fixed_idx] + assert tuple(fixed_arg.args[1:3]) == ( + initial_scale.item(), + initial_zero_point.item(), + ) + assert tuple(regular_arg.args[1:3]) == tuple(dequant.args[1:3]) + + class LoweringWithExportedProgram: @staticmethod def test(compile_spec): diff --git a/backends/qualcomm/tests/rework/utils/test.py b/backends/qualcomm/tests/rework/utils/test.py index b9e0d0aee05..f4acd479317 100644 --- a/backends/qualcomm/tests/rework/utils/test.py +++ b/backends/qualcomm/tests/rework/utils/test.py @@ -61,5 +61,17 @@ def test_qat(subtests): QAT.test(subtests) # noqa: F405 +def test_concat_observer_deepcopy(subtests): + ConcatObserverDeepcopy.test(subtests) # noqa: F405 + + +def test_qat_concat(subtests): + QATConcat.test(subtests) # noqa: F405 + + +def test_qat_concat_with_fixed_qparams_inputs(subtests): + QATConcatWithFixedQParamsInputs.test(subtests) # noqa: F405 + + def test_lowering_with_exported_program(compile_spec): LoweringWithExportedProgram.test(compile_spec) # noqa: F405 From 18e0cc0bd8b9f8c8715b1e119c9b0db167b1f4f3 Mon Sep 17 00:00:00 2001 From: Xiyue Shi Date: Mon, 2 Nov 2026 23:00:44 -0800 Subject: [PATCH 003/128] Qualcomm AI Engine Direct - Add composite lowering for aten.convolution_backward (#23610) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Summary Adds Qualcomm AI Engine Direct composite lowering for `aten.convolution_backward.default` when only the input gradient is requested. This PR builds on work originally implemented by Rajeswaran C Ravindran @rajeswar277 and Mohit Gupta @quic-mohitgupta of the Qualcomm On-Device Learning team. The lowering reuses the static filter wrapper shared with the forward Conv2d, emits the required runtime HWIO→HWOI QNN Transpose, and lowers the operation to QNN TransposeConv2d. This avoids requiring a separate duplicated backward weight. This is implemented as a composite visitor rather than an ATen decomposition: the visitor must reuse the forward Conv2d QNN wrapper and retain the HWOI conversion as a runtime operation. An ATen decomposition would otherwise need another transformed static weight or changes to the forward Conv2d visitor. Supported scope is HTP V69+, floating-point static 2D convolution filters with `groups=1`, `transposed=False`, `dilation=[1, 1]`, and `output_mask=[True, False, False]`. The gradient input/output and filter must be rank-4. Quantized and custom-dequant ConvBackward lowering is intentionally out of scope for this PR and will be added separately. ### Test plan - Structural lowering: - `python -m pytest -q backends/qualcomm/tests/test_passes.py -k "skip_decomp_table_preserves_conv_backward or conv_backward_composite_lowering or conv_backward_quantized_inputs_fallback or layout_transform_tuple_outputs"` - Covers decomposition preservation, composite TransposeConv2d lowering and HWOI-wrapper reuse, quantized fallback, and synthetic tuple-layout edge cases (multiple outputs, `None` outputs, and malformed users). - HTP rework: - `test_conv_backward` passed on V68, V69, V73, V75, V79, and V81. - V68 verifies expected fallback; V69 through V81 verify successful delegation. - Rework pass coverage: - `python -m pytest -q backends/qualcomm/tests/rework/passes/test.py -k "test_layout_transform or test_i64_to_i32"` - Covers FP ConvBackward tuple-output layout placement through the preprocess pipeline, plus optional tuple metadata `(grad_input, None, None)`. - On-device: - `TestQNNFloatingPointOperator.test_qnn_backend_conv_backward` passed on SM8850. - Tuple-output layout regression coverage: - Ran BatchNorm2d, MaxPool2d, GroupNorm, InstanceNorm2d, and AdaptiveMaxPool2d-with-indices on V69 and result matched clean latest `main`. --------- Co-authored-by: rajeswar277 Co-authored-by: quic-mohitgupta --- backends/qualcomm/_passes/i64_to_i32.py | 11 +- backends/qualcomm/_passes/layout_transform.py | 73 ++-- backends/qualcomm/builders/README.md | 14 + backends/qualcomm/builders/__init__.py | 2 + .../qualcomm/builders/op_conv_backward.py | 327 ++++++++++++++++++ .../qualcomm/partition/qnn_partitioner.py | 35 +- backends/qualcomm/partition/utils.py | 1 + backends/qualcomm/tests/models.py | 61 ++++ .../qualcomm/tests/rework/htp/op/v68/test.py | 19 + .../qualcomm/tests/rework/htp/op/v69/test.py | 14 + .../qualcomm/tests/rework/htp/op/v73/test.py | 14 + .../qualcomm/tests/rework/htp/op/v75/test.py | 14 + .../qualcomm/tests/rework/htp/op/v79/test.py | 14 + .../qualcomm/tests/rework/htp/op/v81/test.py | 14 + backends/qualcomm/tests/rework/src/op.py | 46 +++ backends/qualcomm/tests/rework/src/pattern.py | 56 +++ backends/qualcomm/tests/test_passes.py | 169 +++++++++ backends/qualcomm/tests/test_qnn_delegate.py | 11 + 18 files changed, 858 insertions(+), 37 deletions(-) create mode 100644 backends/qualcomm/builders/op_conv_backward.py diff --git a/backends/qualcomm/_passes/i64_to_i32.py b/backends/qualcomm/_passes/i64_to_i32.py index 148564f8cc0..f332bc5064a 100644 --- a/backends/qualcomm/_passes/i64_to_i32.py +++ b/backends/qualcomm/_passes/i64_to_i32.py @@ -73,7 +73,10 @@ def _record_original_output_dtype(self, graph_module: torch.fx.GraphModule): # Keep track of original output dtype so we ensure the dtype of the graph is consistent with nn.Module if is_graph_output(n): if isinstance(n.meta["val"], (tuple, list)): - dtype_list = [tensor.dtype for tensor in n.meta["val"]] + dtype_list = [ + tensor.dtype if tensor is not None else None + for tensor in n.meta["val"] + ] n.meta[QCOM_ORIG_DTYPE] = dtype_list else: n.meta[QCOM_ORIG_DTYPE] = n.meta["val"].dtype @@ -84,6 +87,8 @@ def _preserve_output_dtype(self, graph_module: torch.fx.GraphModule): if isinstance(n.meta["val"], (tuple, list)): for i, dtype in enumerate(n.meta[QCOM_ORIG_DTYPE]): # TODO: Enable this in future to support OP such as topK + if n.meta["val"][i] is None or dtype is None: + continue if n.meta["val"][i].dtype != dtype: raise AssertionError( "Multi output nodes currently don't support casting dtype back." @@ -115,10 +120,10 @@ def _preserve_output_dtype(self, graph_module: torch.fx.GraphModule): def _update_meta(self, node: torch.fx.node) -> None: meta_val = node.meta["val"] if isinstance(meta_val, tuple): - node.meta["val"] = ( + node.meta["val"] = tuple( ( fake_tensor.to(torch.int32) - if fake_tensor.dtype == torch.int64 + if fake_tensor is not None and fake_tensor.dtype == torch.int64 else fake_tensor ) for fake_tensor in meta_val diff --git a/backends/qualcomm/_passes/layout_transform.py b/backends/qualcomm/_passes/layout_transform.py index f2bb7a45f3c..2a2c4d001b9 100644 --- a/backends/qualcomm/_passes/layout_transform.py +++ b/backends/qualcomm/_passes/layout_transform.py @@ -47,6 +47,7 @@ class LayoutTransform(ExportPass): exir_ops.edge.aten.avg_pool2d.default, exir_ops.edge.aten.avg_pool3d.default, exir_ops.edge.aten.convolution.default, + exir_ops.edge.aten.convolution_backward.default, exir_ops.edge.aten.grid_sampler_2d.default, exir_ops.edge.aten.grid_sampler_3d.default, exir_ops.edge.aten.instance_norm.default, @@ -166,16 +167,18 @@ def __init__( def mark_as_transformed(self, node: torch.fx.Node) -> None: if isinstance(node.meta["val"], (tuple, list)): - getitem_node = list(node.users.keys())[0] - if getitem_node.target.__name__ != "getitem": - raise AssertionError( - "Expected node's user to be getitem, " - f"got {getitem_node.target.__name__}" - ) - index = getitem_node.args[1] - node.meta[self.transformed_tag] = self.get_axis_order( - eval_shape(node.meta["val"][index].shape) - ) + for getitem_node in node.users: + if getitem_node.target.__name__ != "getitem": + raise AssertionError( + f"Expected tuple-output node {node.name} ({node.target}) to have a " + f"getitem user, got {getitem_node.target.__name__}" + ) + tensor = node.meta["val"][getitem_node.args[1]] + if tensor is not None: + node.meta[self.transformed_tag] = self.get_axis_order( + eval_shape(tensor.shape) + ) + return else: node.meta[self.transformed_tag] = self.get_axis_order( eval_shape(node.meta["val"].shape) @@ -226,32 +229,47 @@ def is_edge_condition(self, node): return False - def insert_node(self, graph_module, node, revert_layout: bool) -> None: + def insert_node( + self, graph_module, node, revert_layout: bool, layout_node=None + ) -> None: if not self.insert_permute: return - with graph_module.graph.inserting_after(node): - users = node.users.copy() - if isinstance(node.meta["val"], tuple): - getitem_node = list(node.users.keys())[0] + if layout_node is None: + layout_node = node + if isinstance(node.meta["val"], tuple): + getitem_nodes = list(node.users) + for getitem_node in getitem_nodes: if getitem_node.target.__name__ != "getitem": raise AssertionError( - f"Expected bn node's user to be getitem, got {getitem_node.target.__name__}" + f"Expected tuple-output node {node.name} ({node.target}) to have a " + f"getitem user, got {getitem_node.target.__name__}" ) - index = getitem_node.args[1] - tensor = node.meta["val"][index] - else: - tensor = node.meta["val"] + for getitem_node in getitem_nodes: + tensor = node.meta["val"][getitem_node.args[1]] + if tensor is not None: + self.insert_node( + graph_module, + getitem_node, + revert_layout, + layout_node, + ) + return + + tensor = node.meta["val"] + permute_input = node + users = node.users.copy() + with graph_module.graph.inserting_after(permute_input): permute = self.create_call_function_node( graph_module, exir_ops.edge.aten.permute_copy.default, ( - node, + permute_input, self.get_axis_order(eval_shape(tensor.shape), revert_layout), ), ) permute.meta["val"] = tensor - permute.meta[QCOM_QUANT_ATTRS] = node.meta.get(QCOM_QUANT_ATTRS) + permute.meta[QCOM_QUANT_ATTRS] = permute_input.meta.get(QCOM_QUANT_ATTRS) # we need this to check the annotation boundary permute.meta[QCOM_INSERTED_PERMUTE] = True @@ -264,14 +282,14 @@ def insert_node(self, graph_module, node, revert_layout: bool) -> None: # └--------------------------------------> qnn_premute -┙ # i.e. insert permute by condition between user and current node # if there are multiple users included - is_node_transformed = self.is_transformed_node(node) + is_node_transformed = self.is_transformed_node(layout_node) for user in users: is_user_transformed = ( self.is_transformed_node(user) or QCOM_LAYOUT_CHANGE in user.meta ) # insert permute only in exclusive condition if is_node_transformed != is_user_transformed: - user.replace_input_with(node, permute) + user.replace_input_with(permute_input, permute) def create_call_function_node( self, @@ -286,7 +304,12 @@ def create_call_function_node( ) def traverse(self, node: torch.fx.Node, graph_module: torch.fx.GraphModule) -> None: - for arg in node.args: + for index, arg in enumerate(node.args): + if ( + node.target == exir_ops.edge.aten.convolution_backward.default + and index == 1 + ): + continue if isinstance(arg, list): for arg_node in arg: self.annotate_layout(arg_node, graph_module, revert_layout=False) diff --git a/backends/qualcomm/builders/README.md b/backends/qualcomm/builders/README.md index 1f186eff9a9..94318bc40f4 100644 --- a/backends/qualcomm/builders/README.md +++ b/backends/qualcomm/builders/README.md @@ -13,6 +13,7 @@ Thank you for contributing to Qualcomm AI Engine Direct delegate for ExecuTorch. - [Implementation](#implementation) - [Quantizer Annotation](#quantizer-annotation) - [Operator Support Status](#operator-support-status) + - [Additional PyTorch Operators Supported via Composite QNN Lowering](#additional-pytorch-operators-supported-via-composite-qnn-lowering) - [Issues](#issues) - [Pull Requests](#pull-requests) @@ -540,6 +541,19 @@ The following PyTorch operators are supported through decomposition or annotatio | `aten.trunc` | `DecomposeTrunc` | | `aten.var.correction`, `aten.var.dim` | `DecomposeVar` | +## Additional PyTorch Operators Supported via Composite QNN Lowering + +The following PyTorch operators are lowered by a node visitor into multiple QNN operations. + +ConvBackward uses this path to reuse the forward Conv2d's QNN weight wrapper and derive HWOI at runtime; an ATen decomposition would otherwise require another static weight or changes to the forward Conv2d visitor. It currently supports floating-point inputs and weights only; quantized ConvBackward support is planned for a future change. + +| PyTorch Op | Builder | QNN Lowering | +|---|---|---| +| `aten.convolution_backward.default` | `ConvBackward` | `Transpose` → `TransposeConv2d` | +| `aten.copy.default` | `Copy` | `Reshape` → `Tile` | +| `aten.embedding.default` | `Embedding` | `Convert` → `Gather` or `Gather` → `Convert` | +| `aten.index_put.default` | `IndexPutVisitor` | tensor preparation → `ScatterNd` | + ## Issues Please refer to the [issue section](../README.md#issues) for more information. diff --git a/backends/qualcomm/builders/__init__.py b/backends/qualcomm/builders/__init__.py index 3dbb0d219b3..4a9df4afc45 100644 --- a/backends/qualcomm/builders/__init__.py +++ b/backends/qualcomm/builders/__init__.py @@ -29,6 +29,7 @@ op_channel_shuffle, op_clamp, op_conv, + op_conv_backward, op_copy, op_cos, op_cum_sum, @@ -148,6 +149,7 @@ op_channel_shuffle, op_clamp, op_conv, + op_conv_backward, op_copy, op_cos, op_cum_sum, diff --git a/backends/qualcomm/builders/op_conv_backward.py b/backends/qualcomm/builders/op_conv_backward.py new file mode 100644 index 00000000000..e9bc48f945e --- /dev/null +++ b/backends/qualcomm/builders/op_conv_backward.py @@ -0,0 +1,327 @@ +# Copyright (c) Qualcomm Innovation Center, Inc. +# All rights reserved +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import logging +from typing import cast, Dict, List, Optional, Tuple + +import executorch.backends.qualcomm.python.PyQnnManagerAdaptor as PyQnnManager +import numpy as np +import torch +from executorch.backends.qualcomm.utils.constants import QCOM_QUANT_ATTRS + +from .node_visitor import dq_ops, NodeVisitor +from .node_visitor_manager import register_node_visitor +from .qnn_constants import OpTranspose, OpTransposeConv2d, QNN_OP_PACKAGE_NAME_QTI_AISW +from .utils import get_parameter + + +logger = logging.getLogger(__name__) + + +@register_node_visitor +class ConvBackward(NodeVisitor): + """Lower input-gradient Conv2d to TransposeConv2d on HTP V69+. + + Reuses the forward Conv2d weight wrapper instead of materializing another + static weight. + """ + + target = ["aten.convolution_backward.default"] + + def __init__(self, *args) -> None: + super().__init__(*args) + + def _get_supported_input_nodes( + self, node: torch.fx.Node + ) -> Optional[Tuple[torch.fx.Node, torch.Tensor, torch.fx.Node]]: + output_mask = cast(List[bool], node.args[10]) + if output_mask != [True, False, False]: + logger.warning( + "ConvBackward %s is unsupported: only grad_input is supported; " + "got output_mask=%s", + node.name, + output_mask, + ) + return None + if node.args[7]: + logger.warning( + "ConvBackward %s is unsupported: transposed convolutions are not supported", + node.name, + ) + return None + + grad_output_node = node.args[0] + if ( + grad_output_node.target in dq_ops + or grad_output_node.meta.get(QCOM_QUANT_ATTRS) is not None + ): + logger.warning( + "ConvBackward %s is unsupported: quantized grad_output is not supported", + node.name, + ) + return None + grad_output_tensor = self.get_tensor(grad_output_node, node) + if grad_output_tensor.dim() != 4: + logger.warning( + "ConvBackward %s is unsupported: grad_output must be rank 4; got rank %s", + node.name, + grad_output_tensor.dim(), + ) + return None + + filter_node = node.args[2] + if ( + filter_node.target in dq_ops + or filter_node.meta.get(QCOM_QUANT_ATTRS) is not None + ): + logger.warning( + "ConvBackward %s is unsupported: quantized weights are not supported", + node.name, + ) + return None + return grad_output_node, grad_output_tensor, filter_node + + def _get_filter_and_grad_input_tensors( + self, node: torch.fx.Node, filter_node: torch.fx.Node + ) -> Optional[Tuple[torch.Tensor, torch.Tensor]]: + filter_tensor = get_parameter(filter_node, self.edge_program) + if filter_tensor is None: + logger.warning( + "ConvBackward %s is unsupported: weight must be a static parameter", + node.name, + ) + return None + if not filter_tensor.is_floating_point(): + logger.warning( + "ConvBackward %s is unsupported: weight must be floating point; got %s", + node.name, + filter_tensor.dtype, + ) + return None + if filter_tensor.dim() != 4: + logger.warning( + "ConvBackward %s is unsupported: weight must be rank 4; got rank %s", + node.name, + filter_tensor.dim(), + ) + return None + + grad_input_tensor = self.get_tensor(node, node, 0) + if grad_input_tensor.dim() != 4: + logger.warning( + "ConvBackward %s is unsupported: grad_input must be rank 4; got rank %s", + node.name, + grad_input_tensor.dim(), + ) + return None + return filter_tensor, grad_input_tensor + + def _get_transpose_conv_params( + self, + node: torch.fx.Node, + grad_output_node: torch.fx.Node, + filter_tensor: torch.Tensor, + ) -> Optional[Tuple[List[int], List[List[int]], List[int]]]: + stride = cast(List[int], node.args[4]) + padding = cast(List[int], node.args[5]) + dilation = cast(List[int], node.args[6]) + groups = cast(int, node.args[9]) + if ( + len(stride) != 2 + or len(padding) not in (1, 2) + or len(dilation) != 2 + or any(value != 1 for value in dilation) + ): + logger.warning( + "ConvBackward %s is unsupported: requires 2D stride/padding and dilation=1; " + "got stride=%s, padding=%s, dilation=%s", + node.name, + stride, + padding, + dilation, + ) + return None + if groups != 1: + logger.warning( + "ConvBackward %s is unsupported: grouped convolutions are not supported; " + "got groups=%s", + node.name, + groups, + ) + return None + if len(padding) == 1: + padding = padding + padding + padding_2d = [[value, value] for value in padding] + + # Use unpermuted PyTorch metadata; QNN-layout tensors can place spatial + # axes differently. + _, _, kernel_height, kernel_width = filter_tensor.shape + grad_output_height, grad_output_width = grad_output_node.meta["val"].shape[-2:] + grad_input_height, grad_input_width = node.meta["val"][0].shape[-2:] + # Derive QNN's output padding from the desired grad_input shape rather + # than using the forward convolution's output_padding argument. + base_height = ( + stride[0] * (grad_output_height - 1) + kernel_height - 2 * padding[0] + ) + base_width = stride[1] * (grad_output_width - 1) + kernel_width - 2 * padding[1] + output_padding = [ + grad_input_height - base_height, + grad_input_width - base_width, + ] + if any( + value < 0 or value >= stride[index] + for index, value in enumerate(output_padding) + ): + logger.warning( + "ConvBackward %s is unsupported: derived output_padding=%s is invalid " + "for stride=%s", + node.name, + output_padding, + stride, + ) + return None + return stride, padding_2d, output_padding + + def _define_filter_hwoi( + self, + node: torch.fx.Node, + filter_node: torch.fx.Node, + filter_tensor: torch.Tensor, + nodes_to_wrappers: Dict[str, PyQnnManager.TensorWrapper], + ) -> Tuple[PyQnnManager.TensorWrapper, List[PyQnnManager.PyQnnOpWrapper]]: + # Reuse the forward Conv2d's static HWIO wrapper, then transpose it to + # the HWOI layout required by TransposeConv2d. + filter_tensor_hwio = filter_tensor.permute(2, 3, 1, 0).contiguous() + filter_wrapper = self.define_tensor( + filter_node, + node, + filter_tensor_hwio, + PyQnnManager.Qnn_TensorType_t.QNN_TENSOR_TYPE_STATIC, + nodes_to_wrappers, + ) + filter_hwoi_name = f"{filter_node.name}_hwoi" + if filter_hwoi_name in nodes_to_wrappers: + return nodes_to_wrappers[filter_hwoi_name][0], [] + + filter_tensor_hwoi = filter_tensor_hwio.permute(0, 1, 3, 2) + filter_hwoi_wrapper = self.define_custom_tensor_wrapper( + node_name=filter_hwoi_name, + tensor_type=PyQnnManager.Qnn_TensorType_t.QNN_TENSOR_TYPE_NATIVE, + dtype=self.get_data_type(filter_tensor_hwoi, {}), + quant_encoding=PyQnnManager.Qnn_QuantizationEncoding_t.QNN_QUANTIZATION_ENCODING_UNDEFINED, + quant_configs={}, + dims=filter_tensor_hwoi.size(), + tensor=filter_tensor_hwoi, + is_fake_tensor=True, + nodes_to_wrappers=nodes_to_wrappers, + ) + weight_transpose_op = PyQnnManager.PyQnnOpWrapper( + f"{node.name}_weight_to_hwoi", + QNN_OP_PACKAGE_NAME_QTI_AISW, + OpTranspose.op_name, + ) + weight_transpose_op.AddInputTensors([filter_wrapper]) + weight_transpose_op.AddOutputTensors([filter_hwoi_wrapper]) + weight_transpose_op.AddTensorParam( + OpTranspose.param_perm, + PyQnnManager.Qnn_DataType_t.QNN_DATATYPE_UINT_32, + 1, + [4], + np.array([0, 1, 3, 2], dtype=np.uint32), + True, + ) + return filter_hwoi_wrapper, [weight_transpose_op] + + def _define_transpose_conv_op( + self, + node: torch.fx.Node, + grad_output_wrapper: PyQnnManager.TensorWrapper, + filter_hwoi_wrapper: PyQnnManager.TensorWrapper, + grad_input_wrapper: PyQnnManager.TensorWrapper, + stride: List[int], + padding_2d: List[List[int]], + output_padding: List[int], + ) -> PyQnnManager.PyQnnOpWrapper: + transpose_conv_op = PyQnnManager.PyQnnOpWrapper( + f"{node.name}_transpose_conv", + QNN_OP_PACKAGE_NAME_QTI_AISW, + OpTransposeConv2d.op_name, + ) + transpose_conv_op.AddInputTensors([grad_output_wrapper, filter_hwoi_wrapper]) + transpose_conv_op.AddOutputTensors([grad_input_wrapper]) + transpose_conv_op.AddTensorParam( + OpTransposeConv2d.param_stride, + PyQnnManager.Qnn_DataType_t.QNN_DATATYPE_UINT_32, + 1, + [len(stride)], + np.array(stride, dtype=np.uint32), + True, + ) + transpose_conv_op.AddTensorParam( + OpTransposeConv2d.param_pad_amount, + PyQnnManager.Qnn_DataType_t.QNN_DATATYPE_UINT_32, + 2, + [len(padding_2d), len(padding_2d[0])], + np.array(padding_2d, dtype=np.uint32), + True, + ) + transpose_conv_op.AddTensorParam( + OpTransposeConv2d.param_output_padding, + PyQnnManager.Qnn_DataType_t.QNN_DATATYPE_UINT_32, + 1, + [len(output_padding)], + np.array(output_padding, dtype=np.uint32), + True, + ) + return transpose_conv_op + + def define_node( + self, + node: torch.fx.Node, + nodes_to_wrappers: Dict[str, PyQnnManager.TensorWrapper], + ) -> Optional[List[PyQnnManager.PyQnnOpWrapper]]: + supported_inputs = self._get_supported_input_nodes(node) + if supported_inputs is None: + return None + grad_output_node, grad_output_tensor, filter_node = supported_inputs + tensors = self._get_filter_and_grad_input_tensors(node, filter_node) + if tensors is None: + return None + filter_tensor, grad_input_tensor = tensors + params = self._get_transpose_conv_params(node, grad_output_node, filter_tensor) + if params is None: + return None + stride, padding_2d, output_padding = params + + grad_output_wrapper = self.define_tensor( + grad_output_node, + node, + grad_output_tensor, + PyQnnManager.Qnn_TensorType_t.QNN_TENSOR_TYPE_NATIVE, + nodes_to_wrappers, + ) + filter_hwoi_wrapper, weight_transpose_ops = self._define_filter_hwoi( + node, filter_node, filter_tensor, nodes_to_wrappers + ) + + grad_input_wrapper = self.define_tensor( + node, + node, + grad_input_tensor, + PyQnnManager.Qnn_TensorType_t.QNN_TENSOR_TYPE_NATIVE, + nodes_to_wrappers, + ) + + transpose_conv_op = self._define_transpose_conv_op( + node, + grad_output_wrapper, + filter_hwoi_wrapper, + grad_input_wrapper, + stride, + padding_2d, + output_padding, + ) + return weight_transpose_ops + [transpose_conv_op] diff --git a/backends/qualcomm/partition/qnn_partitioner.py b/backends/qualcomm/partition/qnn_partitioner.py index 640d6fbf478..46c853331c6 100644 --- a/backends/qualcomm/partition/qnn_partitioner.py +++ b/backends/qualcomm/partition/qnn_partitioner.py @@ -127,19 +127,18 @@ def __init__( for soc_model in target_socs ] - def is_node_supported(self, _, node: torch.fx.Node) -> bool: # noqa: C901 - if node.op != "call_function" or node.target in not_supported_operator: - return False + def _passes_backend_capability_checks(self, node: torch.fx.Node) -> bool: + # Scoped to HTP: other backends reusing this checker have no htp_arch. + if ( + self.backend_type != QnnExecuTorchBackendType.kHtpBackend + or self.soc_info.htp_info.htp_arch >= HtpArch.V69 + ): + return True # Per-channel-group lowers to blockwise expansion (the encoding LPBQ # uses), which requires HTP >= V69. Pre-quantized weights bypass the # quantizer's validate_lpbq_support check, so gate the delegation here. - # Scoped to HTP: other backends reusing this checker have no htp_arch. - if ( - node.target in PER_CHANNEL_GROUP_ENCODING - and self.backend_type == QnnExecuTorchBackendType.kHtpBackend - and self.soc_info.htp_info.htp_arch < HtpArch.V69 - ): + if node.target in PER_CHANNEL_GROUP_ENCODING: logger.warning( f"[{self.phase}] {node.target.__name__} | Skipped: blockwise " f"(per-channel-group) encoding requires HTP arch >= V69, got " @@ -147,6 +146,24 @@ def is_node_supported(self, _, node: torch.fx.Node) -> bool: # noqa: C901 ) return False + # TransposeConv2d generated for convolution_backward is unsupported by + # the V68 compiler despite passing the QNN op-support query. + if node.target == exir_ops.edge.aten.convolution_backward.default: + logger.warning( + f"[{self.phase}] {node.target.__name__} | Skipped: requires HTP " + f"arch >= V69, got {self.soc_info.htp_info.htp_arch!r}" + ) + return False + + return True + + def is_node_supported(self, _, node: torch.fx.Node) -> bool: # noqa: C901 + if node.op != "call_function" or node.target in not_supported_operator: + return False + + if not self._passes_backend_capability_checks(node): + return False + if node.target in to_be_implemented_operator: logger.info( f"[{self.phase}] {node.target.__name__} | Skipped, this op can be supported, please report an issue in https://github.com/pytorch/executorch/issues" diff --git a/backends/qualcomm/partition/utils.py b/backends/qualcomm/partition/utils.py index 19fa9b1ad55..a608cc42960 100644 --- a/backends/qualcomm/partition/utils.py +++ b/backends/qualcomm/partition/utils.py @@ -242,6 +242,7 @@ def get_skip_decomp_table() -> List[torch._ops.OperatorBase]: torch.ops.aten.adaptive_avg_pool2d.default, torch.ops.aten.channel_shuffle.default, torch.ops.aten.col2im.default, + torch.ops.aten.convolution_backward.default, torch.ops.aten.elu.default, torch.ops.aten.floor_divide.default, torch.ops.aten.hardsigmoid.default, diff --git a/backends/qualcomm/tests/models.py b/backends/qualcomm/tests/models.py index 94d875617c7..926f5e1fb67 100644 --- a/backends/qualcomm/tests/models.py +++ b/backends/qualcomm/tests/models.py @@ -959,6 +959,67 @@ def forward(self, x): return self.second(self.first(x)) +class ConvBackward(torch.nn.Module): + def __init__( + self, + flatten_grad_input=False, + duplicate_backward=False, + return_grad_weight=False, + ): + super().__init__() + self.flatten_grad_input = flatten_grad_input + self.duplicate_backward = duplicate_backward + self.return_grad_weight = return_grad_weight + self.weight = torch.nn.Parameter(torch.randn(4, 3, 3, 3), requires_grad=False) + + def forward(self, grad_output, x): + forward_output = torch.ops.aten.convolution.default( + x, + self.weight, + None, + [2, 2], + [1, 1], + [1, 1], + False, + [0, 0], + 1, + ) + backward_outputs = torch.ops.aten.convolution_backward.default( + grad_output, + x, + self.weight, + None, + [2, 2], + [1, 1], + [1, 1], + False, + [0, 0], + 1, + [True, self.return_grad_weight, False], + ) + grad_input = backward_outputs[0] + if self.flatten_grad_input: + grad_input = torch.flatten(grad_input, 1) + if self.return_grad_weight: + return forward_output, grad_input, torch.flatten(backward_outputs[1], 1) + if not self.duplicate_backward: + return forward_output, grad_input + second_grad_input = torch.ops.aten.convolution_backward.default( + grad_output, + x, + self.weight, + None, + [2, 2], + [1, 1], + [1, 1], + False, + [0, 0], + 1, + [True, False, False], + )[0] + return forward_output, grad_input, second_grad_input + + class ConvFull(torch.nn.Module): def __init__(self, fill, full_shape): super().__init__() diff --git a/backends/qualcomm/tests/rework/htp/op/v68/test.py b/backends/qualcomm/tests/rework/htp/op/v68/test.py index 231c1c95007..506570226fd 100644 --- a/backends/qualcomm/tests/rework/htp/op/v68/test.py +++ b/backends/qualcomm/tests/rework/htp/op/v68/test.py @@ -442,6 +442,25 @@ def test_conv2d_transpose(request, kwargs): Conv.test_2d_transpose(request, kwargs) # noqa: F405 +@pytest.mark.parametrize( + "kwargs", + [ + pytest.param( + { + "act": None, + "expected": pytest.raises( + AssertionError, match=EXPECT_NOT_FULLY_DELEGATED + ), + }, + id="fp_fallback", + ) + ], +) +@with_htp_context +def test_conv_backward(request, kwargs): + ConvBackward.test(request, kwargs) # noqa: F405 + + @pytest.mark.parametrize( "kwargs", [ diff --git a/backends/qualcomm/tests/rework/htp/op/v69/test.py b/backends/qualcomm/tests/rework/htp/op/v69/test.py index 5ff2289f731..4a6b5bd985c 100644 --- a/backends/qualcomm/tests/rework/htp/op/v69/test.py +++ b/backends/qualcomm/tests/rework/htp/op/v69/test.py @@ -501,6 +501,20 @@ def test_conv3d_transpose(request, kwargs): Conv.test_3d_transpose(request, kwargs) # noqa: F405 +@pytest.mark.parametrize( + "kwargs", + [ + pytest.param( + {"act": None, "expected": Tolerance(rtol=1e-1)}, + id="fp", + ), + ], +) +@with_htp_context +def test_conv_backward(request, kwargs): + ConvBackward.test(request, kwargs) # noqa: F405 + + @enumerate_activation_dtype([Tolerance(), Tolerance(), Tolerance(rtol=1e-1)]) @with_htp_context def test_cos(request, kwargs): diff --git a/backends/qualcomm/tests/rework/htp/op/v73/test.py b/backends/qualcomm/tests/rework/htp/op/v73/test.py index fffaba0ee58..eaa4addc61d 100644 --- a/backends/qualcomm/tests/rework/htp/op/v73/test.py +++ b/backends/qualcomm/tests/rework/htp/op/v73/test.py @@ -489,6 +489,20 @@ def test_conv3d_transpose(request, kwargs): Conv.test_3d_transpose(request, kwargs) # noqa: F405 +@pytest.mark.parametrize( + "kwargs", + [ + pytest.param( + {"act": None, "expected": Tolerance(rtol=1e-1)}, + id="fp", + ) + ], +) +@with_htp_context +def test_conv_backward(request, kwargs): + ConvBackward.test(request, kwargs) # noqa: F405 + + @enumerate_activation_dtype([Tolerance(), Tolerance(), Tolerance(rtol=1e-1)]) @with_htp_context def test_cos(request, kwargs): diff --git a/backends/qualcomm/tests/rework/htp/op/v75/test.py b/backends/qualcomm/tests/rework/htp/op/v75/test.py index fffaba0ee58..eaa4addc61d 100644 --- a/backends/qualcomm/tests/rework/htp/op/v75/test.py +++ b/backends/qualcomm/tests/rework/htp/op/v75/test.py @@ -489,6 +489,20 @@ def test_conv3d_transpose(request, kwargs): Conv.test_3d_transpose(request, kwargs) # noqa: F405 +@pytest.mark.parametrize( + "kwargs", + [ + pytest.param( + {"act": None, "expected": Tolerance(rtol=1e-1)}, + id="fp", + ) + ], +) +@with_htp_context +def test_conv_backward(request, kwargs): + ConvBackward.test(request, kwargs) # noqa: F405 + + @enumerate_activation_dtype([Tolerance(), Tolerance(), Tolerance(rtol=1e-1)]) @with_htp_context def test_cos(request, kwargs): diff --git a/backends/qualcomm/tests/rework/htp/op/v79/test.py b/backends/qualcomm/tests/rework/htp/op/v79/test.py index fffaba0ee58..eaa4addc61d 100644 --- a/backends/qualcomm/tests/rework/htp/op/v79/test.py +++ b/backends/qualcomm/tests/rework/htp/op/v79/test.py @@ -489,6 +489,20 @@ def test_conv3d_transpose(request, kwargs): Conv.test_3d_transpose(request, kwargs) # noqa: F405 +@pytest.mark.parametrize( + "kwargs", + [ + pytest.param( + {"act": None, "expected": Tolerance(rtol=1e-1)}, + id="fp", + ) + ], +) +@with_htp_context +def test_conv_backward(request, kwargs): + ConvBackward.test(request, kwargs) # noqa: F405 + + @enumerate_activation_dtype([Tolerance(), Tolerance(), Tolerance(rtol=1e-1)]) @with_htp_context def test_cos(request, kwargs): diff --git a/backends/qualcomm/tests/rework/htp/op/v81/test.py b/backends/qualcomm/tests/rework/htp/op/v81/test.py index fffaba0ee58..eaa4addc61d 100644 --- a/backends/qualcomm/tests/rework/htp/op/v81/test.py +++ b/backends/qualcomm/tests/rework/htp/op/v81/test.py @@ -489,6 +489,20 @@ def test_conv3d_transpose(request, kwargs): Conv.test_3d_transpose(request, kwargs) # noqa: F405 +@pytest.mark.parametrize( + "kwargs", + [ + pytest.param( + {"act": None, "expected": Tolerance(rtol=1e-1)}, + id="fp", + ) + ], +) +@with_htp_context +def test_conv_backward(request, kwargs): + ConvBackward.test(request, kwargs) # noqa: F405 + + @enumerate_activation_dtype([Tolerance(), Tolerance(), Tolerance(rtol=1e-1)]) @with_htp_context def test_cos(request, kwargs): diff --git a/backends/qualcomm/tests/rework/src/op.py b/backends/qualcomm/tests/rework/src/op.py index c81db332873..09122fe5ba5 100644 --- a/backends/qualcomm/tests/rework/src/op.py +++ b/backends/qualcomm/tests/rework/src/op.py @@ -1439,6 +1439,52 @@ def test_3d_transpose(subtests, qnn_config, quantizer, compile_spec, expected): ) +class ConvBackward(torch.nn.Module): + def __init__(self): + super().__init__() + self.weight = torch.nn.Parameter(torch.randn(4, 3, 3, 3), requires_grad=False) + + def forward(self, grad_output, x): + forward_output = torch.ops.aten.convolution.default( + x, + self.weight, + None, + [2, 2], + [1, 1], + [1, 1], + False, + [0, 0], + 1, + ) + grad_input = torch.ops.aten.convolution_backward.default( + grad_output, + x, + self.weight, + None, + [2, 2], + [1, 1], + [1, 1], + False, + [0, 0], + 1, + [True, False, False], + )[0] + return forward_output, grad_input + + @staticmethod + @unpack_fixtures + def test(qnn_config, quantizer, compile_spec, expected): + with expected as metrics: + export_and_verify( + module=__class__(), + inputs=(torch.randn(1, 4, 5, 5), torch.randn(1, 3, 9, 10)), + qnn_config=qnn_config, + quantizer=quantizer, + compile_specs=compile_spec, + metrics=metrics, + ) + + class Cos(torch.nn.Module): def __init__(self): super().__init__() diff --git a/backends/qualcomm/tests/rework/src/pattern.py b/backends/qualcomm/tests/rework/src/pattern.py index 150f9160669..15389c52ca9 100644 --- a/backends/qualcomm/tests/rework/src/pattern.py +++ b/backends/qualcomm/tests/rework/src/pattern.py @@ -4393,6 +4393,29 @@ def __init__(self): def forward(self, x): return self.relu(self.conv(x)) + class _ConvBackward(torch.nn.Module): + def __init__(self): + super().__init__() + self.weight = torch.nn.Parameter( + torch.randn(4, 3, 3, 3), requires_grad=False + ) + + def forward(self, grad_output, x): + grad_input, grad_weight, _ = torch.ops.aten.convolution_backward.default( + grad_output, + x, + self.weight, + None, + [2, 2], + [1, 1], + [1, 1], + False, + [0, 0], + 1, + [True, True, False], + ) + return grad_input.flatten(1), grad_weight.flatten(1) + @staticmethod @unpack_pass_fixtures def test( @@ -4439,6 +4462,39 @@ def test( assertions.assert_target_count(gm, permute, 2) assertions.assert_meta_key_on_targets(gm, permute, QCOM_INSERTED_PERMUTE) + with subtests.test(msg="conv_backward_tuple_outputs"): + gm = pass_pipeline.lower_preprocess_gm( + module=LayoutTransform._ConvBackward(), + sample_input=(torch.randn(1, 4, 4, 4), torch.randn(1, 3, 8, 8)), + backend_type=backend_type, + compile_spec=compile_spec, + target_pass=target_pass, + quantizer=quantizer, + ) + conv_backward = next( + node + for node in gm.graph.nodes + if node.target == exir_ops.edge.aten.convolution_backward.default + ) + getitems = [ + node + for node in conv_backward.users + if node.target is operator.getitem and node.args[1] in (0, 1) + ] + permutes = [ + node + for node in gm.graph.nodes + if node.target == exir_ops.edge.aten.permute_copy.default + ] + assert len(getitems) == 2 + assert all( + any(permute_node.args[0] is getitem for permute_node in permutes) + for getitem in getitems + ) + assert not any( + permute_node.args[0] is conv_backward for permute_node in permutes + ) + class LiftConstantScalarOperands: class _Pow(torch.nn.Module): diff --git a/backends/qualcomm/tests/test_passes.py b/backends/qualcomm/tests/test_passes.py index 2c1818f8a17..a607bcb4d81 100644 --- a/backends/qualcomm/tests/test_passes.py +++ b/backends/qualcomm/tests/test_passes.py @@ -1,4 +1,6 @@ +import operator import unittest +from collections import defaultdict from unittest.mock import MagicMock, patch import torch @@ -12,6 +14,7 @@ FoldQDQ, InsertIOQDQ, InsertReshapeForReduceOps, + LayoutTransform, LiftConstantScalarOperands, RecomposeRmsNorm, Remove0DTensor, @@ -25,6 +28,7 @@ to_dq_op, to_q_op, ) +from executorch.backends.qualcomm.builders.node_visitor_manager import get_node_visitors from executorch.backends.qualcomm.builders.op_custom_op import ( _resolve_qnn_data_type, CustomOp, @@ -32,6 +36,7 @@ from executorch.backends.qualcomm.builders.qnn_constants import OpContextLoader from executorch.backends.qualcomm.builders.utils import is_parameter from executorch.backends.qualcomm.partition.qnn_partitioner import QnnOperatorSupport +from executorch.backends.qualcomm.partition.utils import get_skip_decomp_table from executorch.backends.qualcomm.qnn_preprocess import QnnBackend from executorch.backends.qualcomm.quantizer.quantizer import QnnQuantizer, QuantDtype from executorch.backends.qualcomm.serialization.qc_schema import ( @@ -41,6 +46,7 @@ ) from executorch.backends.qualcomm.tests.models import ( BroadcastAndMutate, + ConvBackward, HardSigmoid, Reciprocal, TopKandIndex, @@ -295,6 +301,169 @@ def test_context_loader_op_lowers_with_ir_validation(self): ) self.assertEqual(1, len(context_loader_nodes)) + def test_skip_decomp_table_preserves_conv_backward(self): + self.assertIn( + torch.ops.aten.convolution_backward.default, + get_skip_decomp_table(), + ) + + def test_conv_backward_composite_lowering(self): + x = torch.randn(1, 3, 8, 8) + grad_output = torch.randn(1, 4, 4, 4) + edge_program = to_edge( + torch.export.export( + ConvBackward(flatten_grad_input=True, duplicate_backward=True), + (grad_output, x), + strict=True, + ) + ).exported_program() + graph_module = LayoutTransform(edge_program, insert_permute=True)( + edge_program.graph_module + ).graph_module + + conv_backwards = [ + node + for node in graph_module.graph.nodes + if node.target == exir_ops.edge.aten.convolution_backward.default + ] + self.assertEqual(2, len(conv_backwards)) + conv_backward = conv_backwards[0] + getitem = next(node for node in conv_backward.users if node.args[1] == 0) + permutes = [ + node + for node in graph_module.graph.nodes + if node.target == exir_ops.edge.aten.permute_copy.default + ] + self.assertTrue(any(node.args[0] is getitem for node in permutes)) + self.assertFalse(any(node.args[0] is conv_backward for node in permutes)) + + forward_conv = next( + node + for node in graph_module.graph.nodes + if node.target == exir_ops.edge.aten.convolution.default + ) + weight_node = forward_conv.args[1] + nodes_to_wrappers = defaultdict(dict) + visitors = get_node_visitors(edge_program) + visitors["aten.convolution.default"].define_node( + forward_conv, nodes_to_wrappers + ) + forward_weight_wrapper = nodes_to_wrappers[weight_node.name][0] + first_ops = visitors["aten.convolution_backward.default"].define_node( + conv_backwards[0], nodes_to_wrappers + ) + first_hwoi_wrapper = nodes_to_wrappers[f"{weight_node.name}_hwoi"][0] + second_ops = visitors["aten.convolution_backward.default"].define_node( + conv_backwards[1], nodes_to_wrappers + ) + second_hwoi_wrapper = nodes_to_wrappers[f"{weight_node.name}_hwoi"][0] + + self.assertEqual(2, len(first_ops)) + self.assertEqual(1, len(second_ops)) + self.assertIs(forward_weight_wrapper, nodes_to_wrappers[weight_node.name][0]) + self.assertIn(f"{weight_node.name}_hwoi", nodes_to_wrappers) + self.assertIs(first_hwoi_wrapper, second_hwoi_wrapper) + + def test_conv_backward_quantized_inputs_fallback(self): + x = torch.randn(1, 3, 8, 8) + grad_output = torch.randn(1, 4, 4, 4) + edge_program = to_edge( + torch.export.export(ConvBackward(), (grad_output, x), strict=True) + ).exported_program() + conv_backward = next( + node + for node in edge_program.graph_module.graph.nodes + if node.target == exir_ops.edge.aten.convolution_backward.default + ) + visitor = get_node_visitors(edge_program)["aten.convolution_backward.default"] + + for input_index in (0, 2): + with self.subTest(input_index=input_index): + conv_backward.args[input_index].meta[QCOM_QUANT_ATTRS] = {} + self.assertIsNone(visitor.define_node(conv_backward, defaultdict(dict))) + conv_backward.args[input_index].meta.pop(QCOM_QUANT_ATTRS) + + def test_layout_transform_tuple_outputs(self): + with self.subTest("multiple_getitems"): + x = torch.randn(1, 3, 8, 8) + grad_output = torch.randn(1, 4, 4, 4) + edge_program = to_edge( + torch.export.export( + ConvBackward(flatten_grad_input=True, return_grad_weight=True), + (grad_output, x), + strict=True, + ) + ).exported_program() + conv_backward = next( + node + for node in edge_program.graph_module.graph.nodes + if node.target == exir_ops.edge.aten.convolution_backward.default + ) + getitems = [ + node + for node in conv_backward.users + if node.target.__name__ == "getitem" and node.args[1] in (0, 1) + ] + # Synthetic metadata exercises LayoutTransform only; quantized + # ConvBackward lowering remains unsupported. + for index, getitem in enumerate(getitems): + getitem.meta[QCOM_QUANT_ATTRS] = {"index": index} + + graph_module = LayoutTransform(edge_program, insert_permute=True)( + edge_program.graph_module + ).graph_module + self.assertEqual(2, len(getitems)) + permutes_by_input = { + node.args[0]: node + for node in graph_module.graph.nodes + if node.target == exir_ops.edge.aten.permute_copy.default + } + self.assertTrue(all(getitem in permutes_by_input for getitem in getitems)) + for getitem in getitems: + self.assertEqual( + getitem.meta[QCOM_QUANT_ATTRS], + permutes_by_input[getitem].meta[QCOM_QUANT_ATTRS], + ) + + with self.subTest("none_output"): + graph = torch.fx.Graph() + conv_backward = graph.call_function( + torch.ops.aten.convolution_backward.default, + (), + ) + conv_backward.meta["val"] = (torch.empty(1, 3, 4, 4), None, None) + graph.call_function(operator.getitem, (conv_backward, 2)) + graph.call_function(operator.getitem, (conv_backward, 0)) + + layout_transform = LayoutTransform(MagicMock()) + layout_transform.mark_as_transformed(conv_backward) + + self.assertEqual( + (0, 2, 3, 1), + conv_backward.meta[layout_transform.transformed_tag], + ) + + with self.subTest("invalid_tuple_user"): + graph = torch.fx.Graph() + tuple_node = graph.call_function( + torch.ops.aten.convolution_backward.default, + (), + ) + tuple_node.meta["val"] = (torch.empty(1, 3, 4, 4),) + getitem_node = graph.call_function(operator.getitem, (tuple_node, 0)) + graph.call_function(torch.ops.aten.alias.default, (tuple_node,)) + graph.output(getitem_node) + graph_module = torch.fx.GraphModule(torch.nn.Module(), graph) + layout_transform = LayoutTransform(MagicMock(), insert_permute=True) + original_nodes = list(graph_module.graph.nodes) + + with self.assertRaisesRegex(AssertionError, "Expected tuple-output node"): + layout_transform.insert_node( + graph_module, tuple_node, revert_layout=False + ) + + self.assertEqual(original_nodes, list(graph_module.graph.nodes)) + def _build_quantized_graph(self): """Build a quantized graph through AnnotateQuantAttrs + FoldQDQ.""" diff --git a/backends/qualcomm/tests/test_qnn_delegate.py b/backends/qualcomm/tests/test_qnn_delegate.py index eca38e27451..0c64c690d47 100644 --- a/backends/qualcomm/tests/test_qnn_delegate.py +++ b/backends/qualcomm/tests/test_qnn_delegate.py @@ -39,6 +39,7 @@ from executorch.backends.qualcomm.qnn_preprocess import DEFAULT_GRAPH_NAME, QnnBackend from executorch.backends.qualcomm.quantizer.rules import Q_ANNOTATION_KEY from executorch.backends.qualcomm.serialization.qc_schema import ( + HtpArch, QcomChipset, QnnExecuTorchBackendType, QnnExecuTorchHtpPerformanceMode, @@ -609,6 +610,16 @@ def test_qnn_backend_conv2d(self): with self.subTest(i=i): self.lower_module_and_test_output(module, sample_input) + def test_qnn_backend_conv_backward(self): + if get_backend_type(self.backend) != QnnExecuTorchBackendType.kHtpBackend: + self.skipTest("convolution_backward is only supported on HTP") + option = flatbuffer_to_option(self.compiler_specs[0].value) + if option.soc_info.htp_info.htp_arch < HtpArch.V69: + self.skipTest("convolution_backward requires HTP V69 or later") + module = ConvBackward() # noqa: F405 + sample_input = (torch.randn(1, 4, 4, 4), torch.randn(1, 3, 8, 8)) + self.lower_module_and_test_output(module, sample_input) + def test_qnn_backend_conv2d_channel_last(self): modules = [ Conv2dSequential(channel_last=True), # noqa: F405 From 274a774bd8b480e73d427a9d5b6dc94a914f2e61 Mon Sep 17 00:00:00 2001 From: Adrian Lundell <36153706+AdrianLundell@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:30:18 +0100 Subject: [PATCH 004/128] Arm backend: Add CONST and use tosa dialect op (#24287) - Add a CONST dialect operation to TOSA dialect + helper function - Add explicit CONST shift to tosa.MUL - Add explicit CONST zero shift to tosa.NEGATE - Add explicit CONST pad values to tosa.PAD This will be used for 1-1 tosa graph serialization from exir graph, for now ignore dialect CONST nodes in the existing serializer flow --------- Signed-off-by: Adrian Lundell --- .../_passes/aten_to_tosa_tensor_operators.py | 36 +++++- backends/arm/_passes/exir_to_tosa_pass.py | 14 ++- .../arm/_passes/insert_dynamic_padding.py | 14 ++- .../arm/_passes/lower_int16_rsqrt_pass.py | 6 +- backends/arm/_passes/match_arg_ranks_pass.py | 16 ++- backends/arm/_passes/rewrite_pad.py | 11 +- backends/arm/_passes/tosa_lowering_utils.py | 29 +++++ backends/arm/operators/op_tosa_mul.py | 13 +- backends/arm/operators/op_tosa_neg.py | 2 +- backends/arm/operators/op_tosa_pad.py | 23 ++-- backends/arm/process_node.py | 6 + .../test_tosa_dialect_constant_visitors.py | 108 ++++++++++++++++ .../misc/tosa_dialect/test_data_layout_ops.py | 10 +- .../test_tosa_dialect_binary_ops.py | 44 +++++-- .../test_tosa_dialect_unary_ops.py | 33 +++-- backends/arm/test/ops/test_to_copy.py | 1 - .../arm/test/passes/test_exir_to_tosa_pass.py | 116 ++++++++++++++++++ .../passes/test_lower_int16_rsqrt_pass.py | 51 ++++++++ .../test/passes/test_match_arg_ranks_pass.py | 35 ++++++ .../passes/test_remove_data_layout_noops.py | 7 +- backends/arm/tosa/dialect/__init__.py | 1 + .../tosa/dialect/ops/binary_elementwise.py | 22 ++-- backends/arm/tosa/dialect/ops/constants.py | 23 ++++ .../arm/tosa/dialect/ops/data_layout_ops.py | 10 +- .../arm/tosa/dialect/ops/unary_elementwise.py | 12 +- 25 files changed, 569 insertions(+), 74 deletions(-) create mode 100644 backends/arm/_passes/tosa_lowering_utils.py create mode 100644 backends/arm/test/misc/test_tosa_dialect_constant_visitors.py create mode 100644 backends/arm/test/passes/test_exir_to_tosa_pass.py create mode 100644 backends/arm/tosa/dialect/ops/constants.py diff --git a/backends/arm/_passes/aten_to_tosa_tensor_operators.py b/backends/arm/_passes/aten_to_tosa_tensor_operators.py index 54f2743d397..cde64cd7691 100644 --- a/backends/arm/_passes/aten_to_tosa_tensor_operators.py +++ b/backends/arm/_passes/aten_to_tosa_tensor_operators.py @@ -3,6 +3,15 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. +from typing import cast + +import torch + +from executorch.backends.arm._passes.fold_qdq_with_annotated_qparams_pass import ( + get_input_qparams, + get_output_qparams, +) +from executorch.backends.arm._passes.tosa_lowering_utils import create_const from executorch.backends.transforms.aten_to_dialect_pass import ( AtenToDialectPass, DialectNodeSpec, @@ -25,6 +34,29 @@ def rewrite_rfft2(node: Node, pass_: AtenToDialectPass) -> DialectNodeSpec | Non ) +def rewrite_mul(node: Node, pass_: AtenToDialectPass) -> DialectNodeSpec: + with node.graph.inserting_before(node): + shift = create_const(node.graph, 0, torch.int8, node) + return DialectNodeSpec(exir_ops.backend.tosa.MUL.default, (*node.args, shift), {}) + + +def rewrite_negate(node: Node, pass_: AtenToDialectPass) -> DialectNodeSpec: + input_node = cast(Node, node.args[0]) + dtype = input_node.meta["val"].dtype + input_zp, output_zp = 0, 0 + if dtype == torch.int8: + input_zp = get_input_qparams(node)[0].get_zp_per_tensor() + output_zp = get_output_qparams(node)[0].get_zp_per_tensor() + with node.graph.inserting_before(node): + input_zero_point = create_const(node.graph, input_zp, dtype, input_node) + output_zero_point = create_const(node.graph, output_zp, dtype, input_node) + return DialectNodeSpec( + exir_ops.backend.tosa.NEGATE.default, + (input_node, input_zero_point, output_zero_point), + {}, + ) + + def rewrite_binary_operator( node: Node, pass_: AtenToDialectPass ) -> DialectNodeSpec | None: @@ -51,8 +83,6 @@ def rewrite_binary_operator( target = exir_ops.backend.tosa.MAXIMUM.default case exir_ops.edge.aten.minimum.default: target = exir_ops.backend.tosa.MINIMUM.default - case exir_ops.edge.aten.mul.Tensor: - target = exir_ops.backend.tosa.MUL.default case exir_ops.edge.aten.pow.Tensor_Tensor: target = exir_ops.backend.tosa.POW.default case exir_ops.edge.aten.sub.Tensor: @@ -83,8 +113,6 @@ def rewrite_unary_operator( target = exir_ops.backend.tosa.LOG.default case exir_ops.edge.aten.logical_not.default: target = exir_ops.backend.tosa.LOGICAL_NOT.default - case exir_ops.edge.aten.neg.default: - target = exir_ops.backend.tosa.NEGATE.default case exir_ops.edge.aten.reciprocal.default: target = exir_ops.backend.tosa.RECIPROCAL.default case exir_ops.edge.aten.rsqrt.default: diff --git a/backends/arm/_passes/exir_to_tosa_pass.py b/backends/arm/_passes/exir_to_tosa_pass.py index 0acb4e59fba..d74b9a6d092 100644 --- a/backends/arm/_passes/exir_to_tosa_pass.py +++ b/backends/arm/_passes/exir_to_tosa_pass.py @@ -20,6 +20,8 @@ ) from executorch.backends.arm._passes.aten_to_tosa_tensor_operators import ( rewrite_binary_operator, + rewrite_mul, + rewrite_negate, rewrite_rfft2, rewrite_unary_operator, ) @@ -65,6 +67,16 @@ def _get_fft_replacement( return rewrite_rfft2(node, pass_) +@register_dialect_substitutions(exir_ops.edge.aten.mul.Tensor) +def _get_mul_replacement(node: Node, pass_: AtenToDialectPass) -> DialectNodeSpec: + return rewrite_mul(node, pass_) + + +@register_dialect_substitutions(exir_ops.edge.aten.neg.default) +def _get_negate_replacement(node: Node, pass_: AtenToDialectPass) -> DialectNodeSpec: + return rewrite_negate(node, pass_) + + @register_dialect_substitutions( exir_ops.edge.aten.add.Tensor, exir_ops.edge.aten.bitwise_and.Tensor, @@ -77,7 +89,6 @@ def _get_fft_replacement( exir_ops.edge.aten.logical_xor.default, exir_ops.edge.aten.maximum.default, exir_ops.edge.aten.minimum.default, - exir_ops.edge.aten.mul.Tensor, exir_ops.edge.aten.pow.Tensor_Tensor, exir_ops.edge.aten.sub.Tensor, ) @@ -107,7 +118,6 @@ def _get_comparison_operator_replacement( exir_ops.edge.aten.floor.default, exir_ops.edge.aten.log.default, exir_ops.edge.aten.logical_not.default, - exir_ops.edge.aten.neg.default, exir_ops.edge.aten.reciprocal.default, exir_ops.edge.aten.rsqrt.default, exir_ops.edge.aten.sin.default, diff --git a/backends/arm/_passes/insert_dynamic_padding.py b/backends/arm/_passes/insert_dynamic_padding.py index 344f7cd5b61..92700255eb4 100644 --- a/backends/arm/_passes/insert_dynamic_padding.py +++ b/backends/arm/_passes/insert_dynamic_padding.py @@ -59,15 +59,23 @@ def call_operator(self, op, args, kwargs, meta, updated=False) -> ProxyValue: zero_spatial_padding = [0] * (spatial_rank * 2) padding_shape = [*zero_padding_pair, *padding, *zero_padding_pair] + pad_value = args[6] if len(args) == 8 else None + if pad_value is None: + pad_value = super().call_operator( + exir_ops.backend.tosa.CONST.default, + ([0], [1], input_tensor.data.dtype), + {}, + meta, + True, + ) pad_res = super().call_operator( exir_ops.backend.tosa.PAD.default, ( input_tensor, padding_shape, + pad_value, ), - { - "value": 0, - }, + {}, meta, True, ) diff --git a/backends/arm/_passes/lower_int16_rsqrt_pass.py b/backends/arm/_passes/lower_int16_rsqrt_pass.py index 8831267efe4..8aafa76f401 100644 --- a/backends/arm/_passes/lower_int16_rsqrt_pass.py +++ b/backends/arm/_passes/lower_int16_rsqrt_pass.py @@ -10,6 +10,7 @@ from executorch.backends.arm._passes import ArmPass from executorch.backends.arm._passes.arm_pass_utils import create_node from executorch.backends.arm._passes.quant_args import QuantArgs +from executorch.backends.arm._passes.tosa_lowering_utils import create_const from executorch.backends.transforms.utils import create_constant_placeholder from executorch.exir import ExportedProgram from executorch.exir.dialects._ops import ops as exir_ops @@ -332,16 +333,17 @@ def call(self, graph_module: GraphModule) -> PassResult: args=(output_node, torch.int32), from_node=node, ) + mul_shift = create_const(graph, 0, torch.int8, node) saturated_zero = create_node( graph, self._MUL, - args=(qmax_output_i32, zero_mask), + args=(qmax_output_i32, zero_mask, mul_shift), from_node=node, ) masked_output = create_node( graph, self._MUL, - args=(output_i32, nonzero_mask), + args=(output_i32, nonzero_mask, mul_shift), from_node=node, ) output_node = create_node( diff --git a/backends/arm/_passes/match_arg_ranks_pass.py b/backends/arm/_passes/match_arg_ranks_pass.py index 1fafb55a231..c2947927c6a 100644 --- a/backends/arm/_passes/match_arg_ranks_pass.py +++ b/backends/arm/_passes/match_arg_ranks_pass.py @@ -85,10 +85,11 @@ def __init__(self, exported_program: ExportedProgram, *args, **kwargs) -> None: exir_ops.backend.tosa.SUB.default, ] - def _match_op_rank(self, graph_module, node, arg, max_rank): + def _match_op_rank(self, graph_module, node, arg_index, max_rank): """In graph_module, insert a view between arg and node to make the rank of arg match the other args to node. """ + arg = node.args[arg_index] shape = get_first_fake_tensor(arg).shape rank = len(shape) new_shape = list([1] * (max_rank - rank) + list(shape)) @@ -101,7 +102,7 @@ def _match_op_rank(self, graph_module, node, arg, max_rank): kwargs={}, from_node=node, ) - node.replace_input_with(arg, view) + node.update_arg(arg_index, view) def call(self, graph_module: GraphModule) -> PassResult: modified = False @@ -112,14 +113,19 @@ def call(self, graph_module: GraphModule) -> PassResult: continue # Calculate max rank of all inputs to node + args = ( + node.args[:2] + if node.target == exir_ops.backend.tosa.MUL.default + else node.args + ) max_rank = 0 - for arg in node.args: + for arg in args: if isinstance(arg, Node): shape = get_first_fake_tensor(arg).shape max_rank = max(max_rank, len(shape)) # Adjust output shape of args if needed. - for arg in node.args: + for arg_index, arg in enumerate(args): if not isinstance(arg, Node): continue shape = get_first_fake_tensor(arg).shape @@ -127,7 +133,7 @@ def call(self, graph_module: GraphModule) -> PassResult: if rank == max_rank: continue - self._match_op_rank(graph_module, node, arg, max_rank) + self._match_op_rank(graph_module, node, arg_index, max_rank) modified = True if modified: diff --git a/backends/arm/_passes/rewrite_pad.py b/backends/arm/_passes/rewrite_pad.py index d3631685de0..c4f0bddca7c 100644 --- a/backends/arm/_passes/rewrite_pad.py +++ b/backends/arm/_passes/rewrite_pad.py @@ -70,10 +70,17 @@ def _rewrite_constant_pad(self, input_tensor, pad, value, meta): exir_ops.backend.tosa.CONST_SHAPE.default, (shape,), {}, meta, True ) + pad_value = super().call_operator( + exir_ops.backend.tosa.CONST.default, + ([value], [1], output_dtype), + {}, + meta, + True, + ) return super().call_operator( exir_ops.backend.tosa.PAD.default, - (input_tensor, pad_shape), - {"value": value}, + (input_tensor, pad_shape, pad_value), + {}, meta, True, ) diff --git a/backends/arm/_passes/tosa_lowering_utils.py b/backends/arm/_passes/tosa_lowering_utils.py new file mode 100644 index 00000000000..e08973dcc66 --- /dev/null +++ b/backends/arm/_passes/tosa_lowering_utils.py @@ -0,0 +1,29 @@ +# Copyright 2026 Arm Limited and/or its affiliates. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. +"""Build explicit TOSA operands during lowering.""" + +import torch +from executorch.backends.arm._passes.arm_pass_utils import create_node +from executorch.backends.arm.tosa.mapping import TosaSpecialDtype +from executorch.exir.dialects._ops import ops as exir_ops +from torch.fx import Graph, Node + + +def create_const(graph: Graph, values, dtype: torch.dtype, from_node: Node) -> Node: + if isinstance(values, torch.Tensor): + values = values.flatten().tolist() + elif not isinstance(values, (tuple, list)): + values = [values] + values = list(values) + node = create_node( + graph, + exir_ops.backend.tosa.CONST.default, + (values, [len(values)], dtype), + from_node=from_node, + ) + node.meta.pop(TosaSpecialDtype.meta_key(), None) + fake = from_node.meta["val"] + node.meta["val"] = fake.new_empty((len(values),), dtype=dtype) + return node diff --git a/backends/arm/operators/op_tosa_mul.py b/backends/arm/operators/op_tosa_mul.py index 660540c31a2..c930bfb3d20 100644 --- a/backends/arm/operators/op_tosa_mul.py +++ b/backends/arm/operators/op_tosa_mul.py @@ -3,7 +3,7 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. -from typing import Any, List +from typing import Any, cast, List import torch.fx import tosa_serializer as ts @@ -31,11 +31,11 @@ def define_node( inputs: List[TosaArg], output: TosaArg, ) -> None: - validate_num_inputs(self.target, inputs, 2) - validate_same_dtype(self.target, [*inputs, output]) + validate_num_inputs(self.target, inputs, 3) + validate_same_dtype(self.target, [*inputs[:2], output]) validate_valid_dtype( self.target, - [*inputs, output], + [*inputs[:2], output], [ ts.DType.INT8, ts.DType.INT16, @@ -47,7 +47,10 @@ def define_node( ], ) - shift = tosa_graph.addConst([1], ts.DType.INT8, 0, name=f"{output.name}_shift") + shift_node = cast(torch.fx.Node, node.args[2]) + shift = tosa_graph.addConst( + [1], ts.DType.INT8, shift_node.args[0], name=f"{output.name}_shift" + ) attr = ts.TosaSerializerAttribute() attr.MulAttribute() self._serialize_operator( diff --git a/backends/arm/operators/op_tosa_neg.py b/backends/arm/operators/op_tosa_neg.py index f5c8505581a..93a0029986b 100644 --- a/backends/arm/operators/op_tosa_neg.py +++ b/backends/arm/operators/op_tosa_neg.py @@ -55,7 +55,7 @@ def define_node( if self.tosa_spec.support_extension("int64"): supported_dtypes.append(ts.DType.INT64) - validate_num_inputs(self.target, inputs, 1) + validate_num_inputs(self.target, inputs, 3) validate_same_dtype(self.target, [*inputs, output]) validate_valid_dtype(self.target, [*inputs, output], supported_dtypes) diff --git a/backends/arm/operators/op_tosa_pad.py b/backends/arm/operators/op_tosa_pad.py index a2645463834..261d8dd0add 100644 --- a/backends/arm/operators/op_tosa_pad.py +++ b/backends/arm/operators/op_tosa_pad.py @@ -3,7 +3,7 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. -from typing import Any, List +from typing import Any, cast, List import torch @@ -20,6 +20,7 @@ validate_valid_dtype, ) from executorch.backends.arm.tosa.mapping import TosaArg +from executorch.exir.dialects._ops import ops as exir_ops @register_node_visitor @@ -37,19 +38,23 @@ def define_node( ) -> None: supported_dtypes = supported_data_layout_dtypes(self.tosa_spec) - validate_num_inputs(self.target, inputs, 2) + validate_num_inputs(self.target, inputs, 3) validate_same_dtype(self.target, [inputs[0], output]) validate_valid_dtype( self.target, [inputs[0], output], supported_dtypes, ) - pad_const = tosa_graph.addConst( - [1], - output.dtype, - [node.kwargs.get("value", 0)], - name=node.name + "_padding_value", - ) + value_node = cast(torch.fx.Node, node.args[2]) + pad_value_name = inputs[2].name + if value_node.target == exir_ops.backend.tosa.CONST.default: + pad_value = tosa_graph.addConst( + [1], + output.dtype, + value_node.args[0], + name=node.name + "_padding_value", + ) + pad_value_name = pad_value.name attr = ts.TosaSerializerAttribute() attr.PadAttribute() @@ -61,7 +66,7 @@ def define_node( [ inputs[0].name, inputs[1].name, - pad_const.name, + pad_value_name, ], [output.name], attr, diff --git a/backends/arm/process_node.py b/backends/arm/process_node.py index aaa644266f3..180da24ab01 100644 --- a/backends/arm/process_node.py +++ b/backends/arm/process_node.py @@ -17,6 +17,7 @@ from executorch.backends.arm.tosa.mapping import TosaArg from executorch.backends.arm.tosa.specification import TosaSpecification from executorch.backends.arm.tosa.utils import normalize_symint +from executorch.exir.dialects._ops import ops as exir_ops from torch._export.utils import ( get_buffer, get_lifted_tensor_constant, @@ -144,6 +145,11 @@ def process_call_function( node_visitors: Dict[str, NodeVisitor], tosa_spec: TosaSpecification, ): + if node.target == exir_ops.backend.tosa.CONST.default: + # Temporarily skip dialect constants: visitors still create serializer + # constants until the node-visitor flow is replaced by direct lowering. + return + # Unpack arguments and convert try: inputs = [TosaArg(arg, tosa_spec) for arg in node.args] diff --git a/backends/arm/test/misc/test_tosa_dialect_constant_visitors.py b/backends/arm/test/misc/test_tosa_dialect_constant_visitors.py new file mode 100644 index 00000000000..770a496ba45 --- /dev/null +++ b/backends/arm/test/misc/test_tosa_dialect_constant_visitors.py @@ -0,0 +1,108 @@ +# Copyright 2026 Arm Limited and/or its affiliates. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import executorch.backends.arm.tosa.dialect # noqa: F401 +import numpy as np +import pytest +import torch +from executorch.backends.arm._passes.quant_args import QuantArgs +from executorch.backends.arm._passes.tosa_lowering_utils import create_const +from executorch.backends.arm.operators.node_visitor import get_node_visitors +from executorch.backends.arm.process_node import process_call_function, process_inputs +from executorch.backends.arm.tosa.constant_pool import TosaSerializerWithConstantPool +from executorch.backends.arm.tosa.mapping import TosaSpecialDtype +from executorch.backends.arm.tosa.specification import TosaSpecification +from executorch.exir.dialects._ops import ops as exir_ops +from torch._subclasses.fake_tensor import FakeTensorMode +from torch.fx import Graph +from tosa.TosaGraph import TosaGraph # type: ignore[import-untyped] + + +@pytest.mark.parametrize( + "target,dtype,values", + [ + (exir_ops.backend.tosa.MUL.default, torch.float32, [0]), + (exir_ops.backend.tosa.MUL.default, torch.int32, [17]), + (exir_ops.backend.tosa.NEGATE.default, torch.float32, [0, 0]), + (exir_ops.backend.tosa.NEGATE.default, torch.int8, [3, -4]), + (exir_ops.backend.tosa.PAD.default, torch.float32, [2.5]), + (exir_ops.backend.tosa.PAD.default, torch.int8, [7]), + ], +) +def test_visitors_recreate_constants_without_serializing_dialect_const( + target, dtype, values +): + spec = TosaSpecification.create_from_string("TOSA-1.0+INT+FP") + visitors = get_node_visitors(spec) + assert "tosa.CONST.default" not in visitors + serializer = TosaSerializerWithConstantPool( + "", targetMajor=1, targetMinor=0, targetPatch=0, targetDraft=False + ) + graph = Graph() + tensor = graph.placeholder("input_tensor") + with FakeTensorMode(): + tensor.meta["val"] = torch.empty(2, 3, dtype=dtype) + constant_dtype = ( + torch.int8 if target == exir_ops.backend.tosa.MUL.default else dtype + ) + constants = [create_const(graph, value, constant_dtype, tensor) for value in values] + if target == exir_ops.backend.tosa.MUL.default: + args = (tensor, tensor, constants[0]) + elif target == exir_ops.backend.tosa.NEGATE.default: + args = (tensor, *constants) + else: + padding = graph.call_function( + exir_ops.backend.tosa.CONST_SHAPE.default, ([0, 0, 1, 1],) + ) + padding.meta["val"] = [0, 0, 1, 1] + padding.meta[TosaSpecialDtype.meta_key()] = TosaSpecialDtype.SHAPE + args = (tensor, padding, constants[0]) + operation = graph.call_function(target, args) + operation.meta["val"] = tensor.meta["val"] + if target == exir_ops.backend.tosa.NEGATE.default and dtype == torch.int8: + operation.meta["input_qparams"] = { + 0: QuantArgs(0.5, values[0], -128, 127, dtype) + } + operation.meta["output_qparams"] = { + 0: QuantArgs(0.5, values[1], -128, 127, dtype) + } + graph.output(operation) + + process_inputs(tensor, serializer, spec) + for node in graph.nodes: + if node.op == "call_function": + process_call_function(node, serializer, visitors, spec) + + block = TosaGraph.GetRootAs(serializer.serialize(), 0).Regions(0).Blocks(0) + tensor_names = { + block.Tensors(index).Name().decode() for index in range(block.TensorsLength()) + } + assert all(constant.name not in tensor_names for constant in constants) + declared_names = tensor_names | { + block.Shapes(index).Name().decode() for index in range(block.ShapesLength()) + } + for index in range(block.OperatorsLength()): + serialized = block.Operators(index) + assert all( + serialized.Inputs(input_index).decode() in declared_names + for input_index in range(serialized.InputsLength()) + ) + operation_inputs = block.Operators(block.OperatorsLength() - 1) + constant_inputs_start = 1 if target == exir_ops.backend.tosa.NEGATE.default else 2 + numpy_dtype = { + torch.float32: np.float32, + torch.int8: np.int8, + torch.int32: np.int32, + }[constant_dtype] + for index, expected in enumerate(values, start=constant_inputs_start): + name = operation_inputs.Inputs(index).decode() + serialized_tensor = next( + block.Tensors(tensor_index) + for tensor_index in range(block.TensorsLength()) + if block.Tensors(tensor_index).Name().decode() == name + ) + assert np.frombuffer( + serialized_tensor.DataAsNumpy(), dtype=numpy_dtype + ).tolist() == [expected] diff --git a/backends/arm/test/misc/tosa_dialect/test_data_layout_ops.py b/backends/arm/test/misc/tosa_dialect/test_data_layout_ops.py index 35074fc32b5..e82eee850c7 100644 --- a/backends/arm/test/misc/tosa_dialect/test_data_layout_ops.py +++ b/backends/arm/test/misc/tosa_dialect/test_data_layout_ops.py @@ -26,7 +26,9 @@ def _fake_tensor(dtype: torch.dtype, mode: FakeTensorMode) -> torch.Tensor: id="concat", ), pytest.param( - lambda x: exir_ops.backend.tosa.PAD.default(x, [1, 2, 3, 4], value=0), + lambda x: exir_ops.backend.tosa.PAD.default( + x, [1, 2, 3, 4], value=x.new_zeros((1,)) + ), (5, 10), id="pad", ), @@ -89,7 +91,9 @@ def test_data_layout_ops_positive(op, expected_shape, spec, dtype) -> None: id="concat", ), pytest.param( - lambda x: exir_ops.backend.tosa.PAD.default(x, [0, -1, 0, 0], value=0), + lambda x: exir_ops.backend.tosa.PAD.default( + x, [0, -1, 0, 0], value=x.new_zeros((1,)) + ), "non-negative", id="pad", ), @@ -165,7 +169,7 @@ def test_pad_rejects_wrong_padding_length() -> None: exir_ops.backend.tosa.PAD.default( mode.from_tensor(torch.randn((2, 3), dtype=torch.float32)), [1, 2], - value=0.0, + value=torch.zeros(1), ) diff --git a/backends/arm/test/misc/tosa_dialect/test_tosa_dialect_binary_ops.py b/backends/arm/test/misc/tosa_dialect/test_tosa_dialect_binary_ops.py index 950cd88a9e3..737cf4996d6 100644 --- a/backends/arm/test/misc/tosa_dialect/test_tosa_dialect_binary_ops.py +++ b/backends/arm/test/misc/tosa_dialect/test_tosa_dialect_binary_ops.py @@ -194,6 +194,8 @@ def test_tosa_binary_ops( with TosaLoweringContext( TosaSpecification.create_from_string(spec) ), FakeTensorMode() as mode: + if op_name == "MUL": + kwargs = {**kwargs, "shift": torch.zeros(1, dtype=torch.int8)} output = getattr(exir_ops.backend.tosa, op_name).default( *_to_fake(mode, input1, input2), **kwargs, @@ -343,7 +345,32 @@ def test_pow_accepts_bfloat16_with_bf16_extension() -> None: assert tuple(output.shape) == tuple(input1.shape) -def test_mul_rejects_non_zero_shift_for_non_int32() -> None: +@pytest.mark.parametrize("shift", [-1, 64]) +def test_mul_rejects_out_of_range_shift(shift): + from executorch.backends.arm.tosa.dialect.ops.binary_elementwise import MUL + + with TosaLoweringContext(TosaSpecification.create_from_string("TOSA-1.1+INT")): + with pytest.raises(TosaValueError, match="range"): + MUL( + torch.ones(2, dtype=torch.int32), + torch.ones(2, dtype=torch.int32), + torch.tensor([shift], dtype=torch.int8), + ) + + +def test_mul_rejects_nonzero_shift_for_narrow_input(): + from executorch.backends.arm.tosa.dialect.ops.binary_elementwise import MUL + + with TosaLoweringContext(TosaSpecification.create_from_string("TOSA-1.1+INT")): + with pytest.raises(TosaValueError, match="Only int32"): + MUL( + torch.ones(2, dtype=torch.int8), + torch.ones(2, dtype=torch.int8), + torch.tensor([1], dtype=torch.int8), + ) + + +def test_mul_rejects_non_int8_shift() -> None: input1 = torch.randint(-8, 8, (2, 3), dtype=torch.int8) input2 = torch.randint(-8, 8, (2, 3), dtype=torch.int8) @@ -352,11 +379,11 @@ def test_mul_rejects_non_zero_shift_for_non_int32() -> None: ), FakeTensorMode() as mode: with pytest.raises( TosaValueError, - match="Only int32 MUL supports a non-zero shift value", + match="MUL shift must be a single-element int8 tensor", ): exir_ops.backend.tosa.MUL.default( *_to_fake(mode, input1, input2), - shift=3, + shift=torch.tensor([3], dtype=torch.int32), ) @@ -411,15 +438,14 @@ def test_binary_ops_take_int64_only_with_the_extension( with TosaLoweringContext( TosaSpecification.create_from_string(spec) ), FakeTensorMode() as mode: + args = _to_fake(mode, input1, input2) + if op_name == "MUL": + args.append(torch.zeros(1, dtype=torch.int8)) if not accepted: with pytest.raises(TosaValueError): - getattr(exir_ops.backend.tosa, op_name).default( - *_to_fake(mode, input1, input2) - ) + getattr(exir_ops.backend.tosa, op_name).default(*args) return - output = getattr(exir_ops.backend.tosa, op_name).default( - *_to_fake(mode, input1, input2) - ) + output = getattr(exir_ops.backend.tosa, op_name).default(*args) assert output.dtype == torch.int64 assert tuple(output.shape) == (2, 4, 3) diff --git a/backends/arm/test/misc/tosa_dialect/test_tosa_dialect_unary_ops.py b/backends/arm/test/misc/tosa_dialect/test_tosa_dialect_unary_ops.py index 125ad0d7046..37b084de246 100644 --- a/backends/arm/test/misc/tosa_dialect/test_tosa_dialect_unary_ops.py +++ b/backends/arm/test/misc/tosa_dialect/test_tosa_dialect_unary_ops.py @@ -127,9 +127,11 @@ def test_tosa_unary_ops( with TosaLoweringContext( TosaSpecification.create_from_string(spec) ), FakeTensorMode() as mode: - output = getattr(exir_ops.backend.tosa, op_name).default( - mode.from_tensor(input_tensor) - ) + operands: tuple[torch.Tensor, ...] = (mode.from_tensor(input_tensor),) + if op_name == "NEGATE": + zero_point = torch.zeros(1, dtype=input_tensor.dtype) + operands += (zero_point, zero_point) + output = getattr(exir_ops.backend.tosa, op_name).default(*operands) assert output.dtype == input_tensor.dtype assert tuple(output.shape) == tuple(input_tensor.shape) @@ -346,9 +348,11 @@ def test_tosa_float_unary_ops_accept_bfloat16_with_bf16_extension( with TosaLoweringContext( TosaSpecification.create_from_string("TOSA-1.1+FP+bf16") ), FakeTensorMode() as mode: - output = getattr(exir_ops.backend.tosa, op_name).default( - mode.from_tensor(input_tensor) - ) + operands: tuple[torch.Tensor, ...] = (mode.from_tensor(input_tensor),) + if op_name == "NEGATE": + zero_point = torch.zeros(1, dtype=input_tensor.dtype) + operands += (zero_point, zero_point) + output = getattr(exir_ops.backend.tosa, op_name).default(*operands) assert output.dtype == torch.bfloat16 assert tuple(output.shape) == tuple(input_tensor.shape) @@ -360,8 +364,11 @@ def test_negate_rejects_bfloat16_without_bf16_extension() -> None: with TosaLoweringContext( TosaSpecification.create_from_string("TOSA-1.1+FP") ), FakeTensorMode() as mode: + zero_point = torch.zeros(1, dtype=sample_input.dtype) with pytest.raises(TosaValueError, match="doesn't support bfloat16"): - exir_ops.backend.tosa.NEGATE.default(mode.from_tensor(sample_input)) + exir_ops.backend.tosa.NEGATE.default( + mode.from_tensor(sample_input), zero_point, zero_point + ) def test_abs_rejects_int8() -> None: @@ -406,15 +413,15 @@ def test_unary_ops_take_int64_only_with_the_extension( with TosaLoweringContext( TosaSpecification.create_from_string(spec) ), FakeTensorMode() as mode: + args: list[torch.Tensor] = [mode.from_tensor(input_tensor)] + if op_name == "NEGATE": + zero_point = torch.zeros(1, dtype=torch.int64) + args.extend([zero_point, zero_point]) if not accepted: with pytest.raises(TosaValueError): - getattr(exir_ops.backend.tosa, op_name).default( - mode.from_tensor(input_tensor) - ) + getattr(exir_ops.backend.tosa, op_name).default(*args) return - output = getattr(exir_ops.backend.tosa, op_name).default( - mode.from_tensor(input_tensor) - ) + output = getattr(exir_ops.backend.tosa, op_name).default(*args) assert output.dtype == torch.int64 assert tuple(output.shape) == (2, 3) diff --git a/backends/arm/test/ops/test_to_copy.py b/backends/arm/test/ops/test_to_copy.py index 8cdab9a7e63..cd8daf6bb77 100644 --- a/backends/arm/test/ops/test_to_copy.py +++ b/backends/arm/test/ops/test_to_copy.py @@ -126,7 +126,6 @@ def test_to_tosa_FP(test_data: Tuple): aten_op=[], exir_op=[], ) - # int to int cast is not supported in TOSA+FP profile if not new_dtype.is_floating_point and not torch.is_floating_point(test_tensor): pipeline.change_args( "check_count.exir", diff --git a/backends/arm/test/passes/test_exir_to_tosa_pass.py b/backends/arm/test/passes/test_exir_to_tosa_pass.py new file mode 100644 index 00000000000..bad3ce5162c --- /dev/null +++ b/backends/arm/test/passes/test_exir_to_tosa_pass.py @@ -0,0 +1,116 @@ +# Copyright 2026 Arm Limited and/or its affiliates. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import pytest +import torch +from executorch.backends.arm._passes.exir_to_tosa_pass import ExirToTosaPass +from executorch.backends.arm._passes.quant_args import QuantArgs +from executorch.backends.arm._passes.rewrite_argmax_pass import RewriteArgmaxPass +from executorch.backends.arm.tosa.specification import ( + TosaLoweringContext, + TosaSpecification, +) +from executorch.exir import to_edge +from executorch.exir.dialects._ops import ops as exir_ops + + +class ArgmaxToInt32(torch.nn.Module): + def forward(self, tensor): + return tensor.argmax(dim=1).to(torch.int32) + + +class CastToInt32(torch.nn.Module): + def forward(self, tensor): + return tensor.to(torch.int32) + + +class ArgmaxCastChain(torch.nn.Module): + def forward(self, tensor): + return tensor.argmax(dim=1).to(torch.int32).to(torch.float32) + + +class CastChain(torch.nn.Module): + def forward(self, tensor): + return tensor.to(torch.int32).to(torch.float32) + + +class PointwiseOperands(torch.nn.Module): + def forward(self, tensor, other): + return tensor * other, -tensor + + +@pytest.mark.parametrize( + "module,expected_casts,expected_dtype", + [ + (ArgmaxToInt32(), 0, torch.int32), + (CastToInt32(), 1, torch.int32), + (ArgmaxCastChain(), 1, torch.float32), + (CastChain(), 2, torch.float32), + ], +) +def test_lowering_preserves_remaining_dimension_order_copies( + module, expected_casts, expected_dtype +): + inputs = (torch.randn(2, 3),) + program = to_edge(torch.export.export(module, inputs)).exported_program() + with TosaLoweringContext(TosaSpecification.create_from_string("TOSA-1.0+FP")): + rewritten = RewriteArgmaxPass()(program.graph_module) + assert rewritten is not None + result = ExirToTosaPass(program)(rewritten.graph_module) + assert result is not None + assert ( + sum( + node.target == exir_ops.edge.dim_order_ops._to_dim_order_copy.default + for node in result.graph_module.graph.nodes + ) + == expected_casts + ) + assert not any( + node.target == exir_ops.backend.tosa.CAST.default + for node in result.graph_module.graph.nodes + ) + output = result.graph_module.graph.output_node().args[0][0] + assert output.meta["val"].dtype == expected_dtype + + +@pytest.mark.parametrize("dtype", (torch.float32, torch.int8, torch.int16, torch.int32)) +def test_pointwise_rewrites_materialize_explicit_operands(dtype): + inputs = (torch.ones(2, 3, dtype=dtype), torch.ones(2, 3, dtype=dtype)) + program = to_edge( + torch.export.export(PointwiseOperands(), inputs) + ).exported_program() + input_zp, output_zp = 0, 0 + if dtype == torch.int8: + input_zp, output_zp = 3, -4 + negate = next( + node + for node in program.graph.nodes + if node.target == exir_ops.edge.aten.neg.default + ) + negate.meta["input_qparams"] = {0: QuantArgs(0.5, input_zp, -128, 127, dtype)} + negate.meta["output_qparams"] = {0: QuantArgs(0.5, output_zp, -128, 127, dtype)} + + with TosaLoweringContext(TosaSpecification.create_from_string("TOSA-1.0+INT+FP")): + result = ExirToTosaPass(program)(program.graph_module) + assert result is not None + result.graph_module.graph.lint() + multiply = next( + node + for node in result.graph_module.graph.nodes + if node.target == exir_ops.backend.tosa.MUL.default + ) + assert len(multiply.args) == 3 + shift = multiply.args[2] + assert shift.target == exir_ops.backend.tosa.CONST.default + assert shift.args == ([0], [1], torch.int8) + + negate = next( + node + for node in result.graph_module.graph.nodes + if node.target == exir_ops.backend.tosa.NEGATE.default + ) + assert len(negate.args) == 3 + assert negate.args[1].args == ([input_zp], [1], dtype) + assert negate.args[2].args == ([output_zp], [1], dtype) diff --git a/backends/arm/test/passes/test_lower_int16_rsqrt_pass.py b/backends/arm/test/passes/test_lower_int16_rsqrt_pass.py index 4423b523e8d..8f975f49c6c 100644 --- a/backends/arm/test/passes/test_lower_int16_rsqrt_pass.py +++ b/backends/arm/test/passes/test_lower_int16_rsqrt_pass.py @@ -3,12 +3,19 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. +import pytest import torch from executorch.backends.arm._passes import InsertTableOpsPass, LowerInt16RsqrtPass from executorch.backends.arm._passes.arm_pass_manager import ArmPassManager +from executorch.backends.arm._passes.quant_args import QuantArgs from executorch.backends.arm.tosa.compile_spec import TosaCompileSpec +from executorch.backends.arm.tosa.specification import ( + TosaLoweringContext, + TosaSpecification, +) from executorch.exir import EdgeCompileConfig, ExportedProgram, to_edge +from executorch.exir.dialects._ops import ops as exir_ops from torch.fx import GraphModule @@ -17,6 +24,11 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: return x + x +class _RsqrtModule(torch.nn.Module): + def forward(self, x: torch.Tensor) -> torch.Tensor: + return torch.rsqrt(x) + + class _PipelineInspectionPassManager(ArmPassManager): def _transform( self, exported_program: ExportedProgram, graph_module: GraphModule @@ -50,3 +62,42 @@ def test_lower_int16_rsqrt_pass_only_added_for_int16_extension() -> None: assert int16_passes.index(LowerInt16RsqrtPass) < int16_passes.index( InsertTableOpsPass ) + + +@pytest.mark.parametrize("output_dtype", (torch.int16, torch.int32)) +def test_lower_int16_rsqrt_mul_uses_explicit_zero_shift(output_dtype): + exported_program = torch.export.export( + _RsqrtModule(), (torch.ones(4),), strict=True + ) + edge_program = to_edge(exported_program, compile_config=EdgeCompileConfig()) + program = edge_program.exported_program() + rsqrt = program.graph_module.graph.find_nodes( + op="call_function", target=exir_ops.edge.aten.rsqrt.default + )[0] + source = rsqrt.args[0] + source.meta["val"] = source.meta["val"].to(dtype=torch.int16) + rsqrt.meta["val"] = rsqrt.meta["val"].to(dtype=output_dtype) + output_limit = torch.iinfo(output_dtype).max + rsqrt.meta.update( + input_qparams={0: QuantArgs(0.1, 0, -32767, 32767, torch.int16)}, + output_qparams={ + 0: QuantArgs(0.1, 0, -output_limit, output_limit, output_dtype) + }, + ) + + with TosaLoweringContext( + TosaSpecification.create_from_string("TOSA-1.0+INT+int16") + ): + result = LowerInt16RsqrtPass(program)(program.graph_module) + + assert result is not None + result.graph_module.graph.lint() + multiplications = result.graph_module.graph.find_nodes( + op="call_function", target=exir_ops.backend.tosa.MUL.default + ) + assert len(multiplications) == 2 + shift = multiplications[0].args[2] + assert all(node.args[2] is shift for node in multiplications) + assert shift.target == exir_ops.backend.tosa.CONST.default + assert shift.args == ([0], [1], torch.int8) + assert shift.meta["val"].dtype == torch.int8 diff --git a/backends/arm/test/passes/test_match_arg_ranks_pass.py b/backends/arm/test/passes/test_match_arg_ranks_pass.py index c8bad012ad7..dd72ede761f 100644 --- a/backends/arm/test/passes/test_match_arg_ranks_pass.py +++ b/backends/arm/test/passes/test_match_arg_ranks_pass.py @@ -8,6 +8,7 @@ import pytest import torch from executorch.backends.arm._passes.match_arg_ranks_pass import MatchArgRanksPass +from executorch.backends.arm._passes.tosa_lowering_utils import create_const from executorch.backends.arm.tosa.specification import ( TosaLoweringContext, TosaSpecification, @@ -50,6 +51,40 @@ def test_match_arg_ranks_handles_tosa_binary_ops() -> None: assert add.all_input_nodes[1] is view +@pytest.mark.parametrize("share_shift_with_data", (False, True)) +def test_match_arg_ranks_preserves_mul_shift(share_shift_with_data): + graph = torch.fx.Graph() + source = graph.placeholder("source") + with FakeTensorMode(): + source.meta["val"] = torch.empty(2, 3, dtype=torch.int8) + shift = create_const(graph, 0, torch.int8, source) + scalar = ( + shift if share_shift_with_data else create_const(graph, 1, torch.int8, source) + ) + mul = graph.call_function( + exir_ops.backend.tosa.MUL.default, (scalar, source, shift) + ) + mul.meta["val"] = source.meta["val"] + graph.output(mul) + + with TosaLoweringContext(TosaSpecification.create_from_string("TOSA-1.0+INT")): + result = MatchArgRanksPass(cast(ExportedProgram, None))( + torch.fx.GraphModule({}, graph) + ) + + assert result is not None + result.graph_module.graph.lint() + mul = result.graph_module.graph.find_nodes( + op="call_function", target=exir_ops.backend.tosa.MUL.default + )[0] + assert mul.args[0].meta["val"].shape == (1, 1) + assert mul.args[1].meta["val"].shape == (2, 3) + shift = mul.args[2] + assert shift.target == exir_ops.backend.tosa.CONST.default + assert shift.args == ([0], [1], torch.int8) + assert shift.meta["val"].shape == (1,) + + @pytest.mark.parametrize("use_tosa", [False, True]) def test_match_arg_ranks_preserves_consumer_provenance(use_tosa: bool) -> None: """Broadcast views retain their consumer's provenance, not their input's.""" diff --git a/backends/arm/test/passes/test_remove_data_layout_noops.py b/backends/arm/test/passes/test_remove_data_layout_noops.py index 081686973ff..93ed6b57418 100644 --- a/backends/arm/test/passes/test_remove_data_layout_noops.py +++ b/backends/arm/test/passes/test_remove_data_layout_noops.py @@ -18,6 +18,7 @@ SymbolicToTosaShapesPass, ) from executorch.backends.arm._passes.arm_pass_manager import ArmPassManager +from executorch.backends.arm._passes.tosa_lowering_utils import create_const from executorch.backends.arm.tosa.compile_spec import TosaCompileSpec from executorch.backends.arm.tosa.dialect.lib import TosaValueError from executorch.backends.arm.tosa.specification import ( @@ -72,7 +73,11 @@ def _tosa_data_layout_graph( ) for values in shape_values ] - kwargs = {"value": 0.0} if target == exir_ops.backend.tosa.PAD.default else {} + kwargs = ( + {"value": create_const(graph, 0.0, x.meta["val"].dtype, x)} + if target == exir_ops.backend.tosa.PAD.default + else {} + ) result = _call( graph, target, diff --git a/backends/arm/tosa/dialect/__init__.py b/backends/arm/tosa/dialect/__init__.py index ee6e56b39fb..ea129031e21 100644 --- a/backends/arm/tosa/dialect/__init__.py +++ b/backends/arm/tosa/dialect/__init__.py @@ -10,6 +10,7 @@ avg_pool2d_adaptive, binary_elementwise, comparison, + constants, conv2d, conv2d_block_scaled, conv3d, diff --git a/backends/arm/tosa/dialect/ops/binary_elementwise.py b/backends/arm/tosa/dialect/ops/binary_elementwise.py index 827e23724ec..200061891df 100644 --- a/backends/arm/tosa/dialect/ops/binary_elementwise.py +++ b/backends/arm/tosa/dialect/ops/binary_elementwise.py @@ -273,24 +273,26 @@ def MINIMUM( @register_fake_tosa_op( - "MUL(Tensor input1, Tensor input2, *, int shift=0) -> Tensor", + "MUL(Tensor input1, Tensor input2, Tensor shift) -> Tensor", TosaSpecification.all_versions_and_profiles(), ) def MUL( input1: torch.Tensor, input2: torch.Tensor, - *, - shift: int = 0, + shift: torch.Tensor, ) -> torch.Tensor: output_dtype = _validate_and_infer_mul_output_dtype(input1.dtype) - if shift < 0 or shift > 63: - raise TosaValueError("shift must be in the range [0, 63]", op="MUL") - if input1.dtype != torch.int32 and shift != 0: - raise TosaValueError( - "Only int32 MUL supports a non-zero shift value", - op="MUL", - ) + if shift.dtype != torch.int8 or shift.shape != (1,): + raise TosaValueError("MUL shift must be a single-element int8 tensor", op="MUL") + if not isinstance(shift, torch._subclasses.fake_tensor.FakeTensor): + shift_value = shift.item() + if not 0 <= shift_value <= 63: + raise TosaValueError("shift must be in the range [0, 63]", op="MUL") + if input1.dtype != torch.int32 and shift_value != 0: + raise TosaValueError( + "Only int32 MUL supports a non-zero shift value", op="MUL" + ) return binary_meta(input1, input2, "MUL", output_dtype=output_dtype) diff --git a/backends/arm/tosa/dialect/ops/constants.py b/backends/arm/tosa/dialect/ops/constants.py new file mode 100644 index 00000000000..aaab9428014 --- /dev/null +++ b/backends/arm/tosa/dialect/ops/constants.py @@ -0,0 +1,23 @@ +# Copyright 2026 Arm Limited and/or its affiliates. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. +"""Explicit tensor constants in the TOSA dialect.""" + +import torch + +from executorch.backends.arm.tosa.dialect.ops_registration import register_tosa_op +from executorch.backends.arm.tosa.specification import TosaSpecification + + +@register_tosa_op( + "CONST(Scalar[] values, int[] shape, ScalarType dtype) -> Tensor", + TosaSpecification.all_versions_and_profiles(), + include_real_impl=False, +) +def CONST( + values: list[int | float], shape: list[int], dtype: torch.dtype +) -> torch.Tensor: + if len(values) != torch.Size(shape).numel(): + raise ValueError("CONST values must match its shape") + return torch.empty(shape, dtype=dtype) diff --git a/backends/arm/tosa/dialect/ops/data_layout_ops.py b/backends/arm/tosa/dialect/ops/data_layout_ops.py index 52ca1366320..d938cdf1c46 100644 --- a/backends/arm/tosa/dialect/ops/data_layout_ops.py +++ b/backends/arm/tosa/dialect/ops/data_layout_ops.py @@ -125,11 +125,17 @@ def CONCAT(inputs: list[torch.Tensor], *, axis: int) -> torch.Tensor: @register_fake_tosa_op( - "PAD(Tensor input1, SymInt[] padding, *, Scalar value) -> Tensor", + "PAD(Tensor input1, SymInt[] padding, Tensor value) -> Tensor", TosaSpecification.all_versions_and_profiles(), ) -def PAD(x: torch.Tensor, padding: list[int | torch.SymInt], *, value) -> torch.Tensor: +def PAD( + x: torch.Tensor, padding: list[int | torch.SymInt], value: torch.Tensor +) -> torch.Tensor: _validate_data_layout_dtype(x.dtype, "PAD") + if value.dtype != x.dtype or value.numel() != 1: + raise TosaValueError( + "PAD value must be a scalar tensor with the input dtype", op="PAD" + ) if len(padding) != 2 * len(x.shape): raise TosaValueError( diff --git a/backends/arm/tosa/dialect/ops/unary_elementwise.py b/backends/arm/tosa/dialect/ops/unary_elementwise.py index 0fed85f2f00..79fcd0f6373 100644 --- a/backends/arm/tosa/dialect/ops/unary_elementwise.py +++ b/backends/arm/tosa/dialect/ops/unary_elementwise.py @@ -163,11 +163,19 @@ def LOGICAL_NOT(input1: torch.Tensor) -> torch.Tensor: @register_fake_tosa_op( - "NEGATE(Tensor input1) -> Tensor", + "NEGATE(Tensor input1, Tensor input_zp, Tensor output_zp) -> Tensor", DUAL_PROFILE_SPECS, ) -def NEGATE(input1: torch.Tensor) -> torch.Tensor: +def NEGATE( + input1: torch.Tensor, input_zp: torch.Tensor, output_zp: torch.Tensor +) -> torch.Tensor: _validate_negate_dtype(input1.dtype) + for zero_point in (input_zp, output_zp): + if zero_point.numel() != 1 or zero_point.dtype != input1.dtype: + raise TosaValueError( + "NEGATE zero points must be scalar tensors matching the input dtype", + op="NEGATE", + ) return torch.empty_like(input1, dtype=input1.dtype) From 0250ab2024224e38586fc84b2ba1f2e9bb2dfe38 Mon Sep 17 00:00:00 2001 From: Erik Lundell Date: Tue, 3 Nov 2026 13:32:57 +0100 Subject: [PATCH 005/128] Arm backend: Collect PMU counters in EthosUExecutionStats (#24432) In the Ethos-U Backend, create a delegate context that is passed to ethosu_invoke_v3 as user_args. When ethosu_execution_stats is linked in, it registers ethosu callbacks that use the context to write to an EthosUStatsBuffer that is owned by the application and registered using `set_ethosu_stats_buffer`. The buffer consists of EthosUExecutionRecords that contain data for one delegate call, including metadata to identify what npu ran the delegate. Additional introduced API: - EthosUBackend_npu_begin() callback - EthosUBackend_npu_end() callback These can be used for measuring CPU time during npu dispatch. - ethosu_execution_metrics_enabled() -> bool Use this infra in arm_perf_monitor. cc @digantdesai @freddan80 @per @zingo @oscarandersson8218 @mansnils @Sebastian-Larsson @robell @rascani --------- Signed-off-by: Erik Lundell --- backends/arm/CMakeLists.txt | 6 +- backends/arm/cmake/ArmRunnerUtils.cmake | 5 + .../arm/cmake/ArmRunnerUtilsInternal.cmake | 22 ++ .../api_manifest_running.toml | 4 + backends/arm/runtime/EthosUBackend.cpp | 5 + .../arm/runtime/EthosUBackend_CoreDriver.cpp | 6 +- backends/arm/runtime/EthosUBackend_Pmu.h | 24 ++ backends/arm/runtime/EthosUExecutionStats.cpp | 121 +++++++ backends/arm/runtime/EthosUExecutionStats.h | 13 + backends/arm/runtime/targets.bzl | 14 + .../arm/runtime/tests/ethos-u/CMakeLists.txt | 43 ++- .../arm/test/ethosu_pmu_callbacks_test.cpp | 116 ++++++ backends/arm/test/ethosu_pmu_test_stubs.cpp | 92 +++++ backends/arm/test/ethosu_pmu_test_stubs.h | 25 ++ backends/arm/test/test_arm_backend.sh | 3 + backends/cortex_m/test/build_test_runner.sh | 2 +- examples/arm/executor_runner/CMakeLists.txt | 34 +- .../executor_runner/arm_executor_runner.cpp | 2 + .../arm/executor_runner/arm_perf_monitor.cpp | 332 +++++++++++------- .../arm/executor_runner/arm_perf_monitor.h | 1 + .../executor_runner/standalone/CMakeLists.txt | 1 - 21 files changed, 745 insertions(+), 126 deletions(-) create mode 100644 backends/arm/runtime/EthosUBackend_Pmu.h create mode 100644 backends/arm/test/ethosu_pmu_callbacks_test.cpp create mode 100644 backends/arm/test/ethosu_pmu_test_stubs.cpp create mode 100644 backends/arm/test/ethosu_pmu_test_stubs.h diff --git a/backends/arm/CMakeLists.txt b/backends/arm/CMakeLists.txt index 2cc8c31790f..69ec45ccd17 100644 --- a/backends/arm/CMakeLists.txt +++ b/backends/arm/CMakeLists.txt @@ -91,12 +91,16 @@ if(EXECUTORCH_BUILD_ARM_BAREMETAL OR EXECUTORCH_BUILD_ARM_ETHOSU_LINUX) ) target_link_libraries(executorch_delegate_ethos_u PUBLIC ethosu_core_driver) add_library( - ethosu_execution_stats STATIC + ethosu_execution_stats STATIC EXCLUDE_FROM_ALL ${EXECUTORCH_ROOT}/backends/arm/runtime/EthosUExecutionStats.cpp ) target_include_directories( ethosu_execution_stats PRIVATE ${_common_include_directories} ) + target_include_directories( + ethosu_execution_stats PRIVATE ${DRIVER_ETHOSU_INCLUDE_DIR} + ) + target_link_libraries(ethosu_execution_stats PUBLIC ethosu_core_driver) elseif(EXECUTORCH_BUILD_ARM_ETHOSU_LINUX) target_sources( executorch_delegate_ethos_u diff --git a/backends/arm/cmake/ArmRunnerUtils.cmake b/backends/arm/cmake/ArmRunnerUtils.cmake index 81e16079f83..12dc25634ec 100644 --- a/backends/arm/cmake/ArmRunnerUtils.cmake +++ b/backends/arm/cmake/ArmRunnerUtils.cmake @@ -11,6 +11,7 @@ include_guard(DIRECTORY) set(ARM_RUNNER_UTILS_API_COMMANDS arm_runner_add_minimal_executable arm_runner_add_standalone_executorch + arm_runner_check_lock_free_atomics arm_runner_configure_ethos_u_platform arm_runner_configure_linker_script arm_runner_configure_model @@ -35,6 +36,10 @@ set(ARM_RUNNER_UTILS_API_KIND_arm_runner_add_standalone_executorch macro) set(ARM_RUNNER_UTILS_API_SIGNATURE_arm_runner_add_standalone_executorch "arm_runner_add_standalone_executorch()" ) +set(ARM_RUNNER_UTILS_API_KIND_arm_runner_check_lock_free_atomics function) +set(ARM_RUNNER_UTILS_API_SIGNATURE_arm_runner_check_lock_free_atomics + "arm_runner_check_lock_free_atomics(OUT_VAR)" +) set(ARM_RUNNER_UTILS_API_KIND_arm_runner_configure_ethos_u_platform function) set(ARM_RUNNER_UTILS_API_SIGNATURE_arm_runner_configure_ethos_u_platform "arm_runner_configure_ethos_u_platform(*, SDK_PATH, SYSTEM_CONFIG, MEMORY_MODE)" diff --git a/backends/arm/cmake/ArmRunnerUtilsInternal.cmake b/backends/arm/cmake/ArmRunnerUtilsInternal.cmake index f246de33451..ab8e795cb72 100644 --- a/backends/arm/cmake/ArmRunnerUtilsInternal.cmake +++ b/backends/arm/cmake/ArmRunnerUtilsInternal.cmake @@ -373,6 +373,28 @@ function(arm_runner_require_baremetal_targets) endfunction() +function(arm_runner_check_lock_free_atomics OUT_VAR) + if(NOT OUT_VAR) + message(FATAL_ERROR "OUT_VAR is required.") + endif() + + include(CheckCXXSourceCompiles) + # Check the selected CPU again if the build directory is reconfigured. + list(APPEND CMAKE_TRY_COMPILE_PLATFORM_VARIABLES TARGET_CPU) + unset(_arm_runner_lock_free_atomics CACHE) + check_cxx_source_compiles( + "#include + static_assert(std::atomic::is_always_lock_free); + static_assert(std::atomic::is_always_lock_free); + int main() { return 0; }" + _arm_runner_lock_free_atomics + ) + set(${OUT_VAR} + ${_arm_runner_lock_free_atomics} + PARENT_SCOPE + ) +endfunction() + function(_arm_runner_verify_targets_exist) cmake_parse_arguments(ARG "" "CONTEXT" "TARGETS" ${ARGN}) diff --git a/backends/arm/public_api_manifests/api_manifest_running.toml b/backends/arm/public_api_manifests/api_manifest_running.toml index 044c90b77c5..7c767d2af51 100644 --- a/backends/arm/public_api_manifests/api_manifest_running.toml +++ b/backends/arm/public_api_manifests/api_manifest_running.toml @@ -238,6 +238,10 @@ signature = "arm_runner_add_minimal_executable(*, TARGET, SOURCE, OPS_PREFIX, CO kind = "macro" signature = "arm_runner_add_standalone_executorch()" +[cmake.arm_runner_check_lock_free_atomics] +kind = "function" +signature = "arm_runner_check_lock_free_atomics(OUT_VAR)" + [cmake.arm_runner_configure_ethos_u_platform] kind = "function" signature = "arm_runner_configure_ethos_u_platform(*, SDK_PATH, SYSTEM_CONFIG, MEMORY_MODE)" diff --git a/backends/arm/runtime/EthosUBackend.cpp b/backends/arm/runtime/EthosUBackend.cpp index 7c45905a3de..3fac2009d15 100644 --- a/backends/arm/runtime/EthosUBackend.cpp +++ b/backends/arm/runtime/EthosUBackend.cpp @@ -23,6 +23,7 @@ #include #include +#include #include #include #include @@ -56,6 +57,10 @@ namespace executorch { namespace backends { namespace arm { +bool __attribute__((weak)) ethosu_execution_metrics_enabled() noexcept { + return false; +} + namespace { using printf_size_t = unsigned long; diff --git a/backends/arm/runtime/EthosUBackend_CoreDriver.cpp b/backends/arm/runtime/EthosUBackend_CoreDriver.cpp index 8be145b9ad7..5d905365329 100644 --- a/backends/arm/runtime/EthosUBackend_CoreDriver.cpp +++ b/backends/arm/runtime/EthosUBackend_CoreDriver.cpp @@ -17,6 +17,7 @@ #include #include +#include #include using executorch::runtime::BackendExecutionContext; @@ -133,6 +134,9 @@ Error platform_execute( handles.scratch_data_size, ethosu_fast_scratch_size}; #endif + executorch::backends::arm::EthosUInvocationContext invocation_context{}; + invocation_context.delegate_handle = + reinterpret_cast(execution_handle); int result = ethosu_invoke_v3( driver.get(), static_cast(handles.cmd_data), @@ -140,7 +144,7 @@ Error platform_execute( bases, bases_size, ETHOSU_NUM_BASE_ADDRS, /* fixed array of pointers to binary interface*/ - nullptr); + &invocation_context); if (result != 0) { ET_LOG(Error, "Ethos-U invocation failed error (%d)", result); diff --git a/backends/arm/runtime/EthosUBackend_Pmu.h b/backends/arm/runtime/EthosUBackend_Pmu.h new file mode 100644 index 00000000000..b80098235c6 --- /dev/null +++ b/backends/arm/runtime/EthosUBackend_Pmu.h @@ -0,0 +1,24 @@ +/* + * Copyright 2026 Arm Limited and/or its affiliates. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ +#pragma once + +#include + +namespace executorch::backends::arm { + +// EthosUStatsBuffer is defined in EthosUExecutionStats.h. +struct EthosUStatsBuffer; + +struct EthosUInvocationContext { + EthosUStatsBuffer* buffer = nullptr; + uintptr_t delegate_handle = 0; + uint32_t counter_count = 0; +}; + +bool ethosu_execution_metrics_enabled() noexcept; + +} // namespace executorch::backends::arm diff --git a/backends/arm/runtime/EthosUExecutionStats.cpp b/backends/arm/runtime/EthosUExecutionStats.cpp index dfa6e801301..92225bea937 100644 --- a/backends/arm/runtime/EthosUExecutionStats.cpp +++ b/backends/arm/runtime/EthosUExecutionStats.cpp @@ -4,4 +4,125 @@ * This source code is licensed under the BSD-style license found in the * LICENSE file in the root directory of this source tree. */ + +#include + +#include +#include + +#include #include + +namespace executorch::backends::arm { +namespace { +std::atomic capture_buffer{nullptr}; +} // namespace + +EthosUStatsBuffer* set_ethosu_stats_buffer(EthosUStatsBuffer* buffer) noexcept { + return capture_buffer.exchange(buffer, std::memory_order_acq_rel); +} + +bool ethosu_execution_metrics_enabled() noexcept { + return true; +} + +} // namespace executorch::backends::arm + +using executorch::backends::arm::EthosUInvocationContext; + +extern "C" { +void __attribute__((weak)) EthosUBackend_npu_begin() {} + +void __attribute__((weak)) EthosUBackend_npu_end() {} + +void ethosu_inference_begin(struct ethosu_driver* drv, void* user_arg) { + auto* invocation = static_cast(user_arg); + if (invocation == nullptr) { + return; + } + + invocation->counter_count = 0; + invocation->buffer = + executorch::backends::arm::capture_buffer.load(std::memory_order_acquire); + if (invocation->buffer != nullptr) { + static constexpr ethosu_pmu_event_type kU55U65Events[] = { + ETHOSU_PMU_AXI0_RD_DATA_BEAT_RECEIVED, + ETHOSU_PMU_AXI1_RD_DATA_BEAT_RECEIVED, + ETHOSU_PMU_AXI0_WR_DATA_BEAT_WRITTEN, + ETHOSU_PMU_NPU_IDLE, + }; + static constexpr ethosu_pmu_event_type kU85Events[] = { + ETHOSU_PMU_SRAM_RD_DATA_BEAT_RECEIVED, + ETHOSU_PMU_SRAM_WR_DATA_BEAT_WRITTEN, + ETHOSU_PMU_EXT_RD_DATA_BEAT_RECEIVED, + ETHOSU_PMU_EXT_WR_DATA_BEAT_WRITTEN, + ETHOSU_PMU_NPU_IDLE, + ETHOSU_PMU_MAC_ACTIVE, + ETHOSU_PMU_WD_ACTIVE, + }; + + const ethosu_pmu_event_type* events = nullptr; + uint32_t event_count = 0; + if (drv->dev.caps.product == ETHOSU_PRODUCT_U55 || + drv->dev.caps.product == ETHOSU_PRODUCT_U65) { + events = kU55U65Events; + event_count = sizeof(kU55U65Events) / sizeof(kU55U65Events[0]); + } else if (drv->dev.caps.product == ETHOSU_PRODUCT_U85) { + events = kU85Events; + event_count = sizeof(kU85Events) / sizeof(kU85Events[0]); + } + + if (events != nullptr) { + invocation->counter_count = + std::min(event_count, ETHOSU_PMU_Get_NumEventCountersForDrv(drv)); + } + if (events != nullptr && invocation->counter_count != 0) { + ETHOSU_PMU_Enable(drv); + ETHOSU_PMU_PMCCNTR_CFG_Set_Stop_Event(drv, ETHOSU_PMU_NPU_IDLE); + ETHOSU_PMU_PMCCNTR_CFG_Set_Start_Event(drv, ETHOSU_PMU_NPU_ACTIVE); + for (uint32_t slot = 0; slot < invocation->counter_count; ++slot) { + ETHOSU_PMU_Set_EVTYPER(drv, slot, events[slot]); + } + const uint32_t event_mask = (1u << invocation->counter_count) - 1u; + ETHOSU_PMU_CNTR_Enable(drv, event_mask | ETHOSU_PMU_CCNT_Msk); + ETHOSU_PMU_CYCCNT_Reset(drv); + ETHOSU_PMU_EVCNTR_ALL_Reset(drv); + ETHOSU_PMU_Set_CNTR_OVS(drv, event_mask | ETHOSU_PMU_CCNT_Msk); + } + } + + // Call callback + EthosUBackend_npu_begin(); +} + +void ethosu_inference_end(struct ethosu_driver* drv, void* user_arg) { + const auto* invocation = static_cast(user_arg); + if (invocation == nullptr) { + return; + } + + if (invocation->buffer != nullptr && invocation->counter_count != 0) { + executorch::backends::arm::EthosUExecutionRecord record{}; + record.delegate_handle = invocation->delegate_handle; + record.success = drv->job.result == ETHOSU_JOB_RESULT_OK; + record.stats.device_identifier = reinterpret_cast(drv->dev.reg); + record.stats.product = drv->dev.caps.product; + record.stats.cycle_count = ETHOSU_PMU_Get_CCNTR(drv); + record.stats.cycle_count_valid = true; + const uint32_t overflow_status = ETHOSU_PMU_Get_CNTR_OVS(drv); + record.stats.cycle_overflow = (overflow_status & ETHOSU_PMU_CCNT_Msk) != 0; + record.stats.counter_count = invocation->counter_count; + for (uint32_t slot = 0; slot < invocation->counter_count; ++slot) { + record.stats.counters[slot] = { + static_cast(ETHOSU_PMU_Get_EVTYPER(drv, slot)), + ETHOSU_PMU_Get_EVCNTR(drv, slot), + (overflow_status & (1u << slot)) != 0}; + } + ETHOSU_PMU_Disable(drv); + EthosUBackend_npu_end(); + invocation->buffer->append(record); + return; + } + EthosUBackend_npu_end(); +} +} diff --git a/backends/arm/runtime/EthosUExecutionStats.h b/backends/arm/runtime/EthosUExecutionStats.h index 98e6d255dae..fa04759f237 100644 --- a/backends/arm/runtime/EthosUExecutionStats.h +++ b/backends/arm/runtime/EthosUExecutionStats.h @@ -11,6 +11,12 @@ #include #include +// Optional runner hooks. The stats library provides weak no-op definitions. +extern "C" { +void EthosUBackend_npu_begin(); +void EthosUBackend_npu_end(); +} + namespace executorch::backends::arm { // Containers of Ethos-U metrics. @@ -21,6 +27,13 @@ struct EthosUExecutionStats; struct EthosUExecutionRecord; struct EthosUStatsBuffer; +// Public API of the ethosu_execution_stats library. +// Register runner-owned storage for subsequent invocations. Pass nullptr to +// stop capture; replace the buffer only when no invocations are running. +EthosUStatsBuffer* set_ethosu_stats_buffer(EthosUStatsBuffer* buffer) noexcept; +// Returns true when this library is linked. +bool ethosu_execution_metrics_enabled() noexcept; + constexpr size_t kMaxPmuEventCounters = 8; enum class EthosUPlatform : uint8_t { diff --git a/backends/arm/runtime/targets.bzl b/backends/arm/runtime/targets.bzl index 519595769d9..8ddabe90ade 100644 --- a/backends/arm/runtime/targets.bzl +++ b/backends/arm/runtime/targets.bzl @@ -22,6 +22,19 @@ def _vgf_zero_copy_flags(): return ["-DEXECUTORCH_VGF_ZERO_COPY_IO=0"] def define_common_targets(): + runtime.cxx_library( + name = "ethosu_invocation_context", + exported_headers = ["EthosUBackend_Pmu.h"], + visibility = ["PUBLIC"], + ) + runtime.cxx_library( + name = "ethosu_execution_stats", + srcs = ["EthosUExecutionStats.cpp"], + exported_headers = ["EthosUExecutionStats.h"], + exported_deps = [":ethosu_invocation_context"], + visibility = ["PUBLIC"], + deps = ["fbsource//third-party/ethos-u-core-driver:core_driver_headers_only"], + ) runtime.cxx_library( name = "vela_bin_stream", srcs = ["VelaBinStream.cpp"], @@ -54,6 +67,7 @@ def define_common_targets(): visibility = ["PUBLIC"], deps = [ "//executorch/runtime/backend:interface", + ":ethosu_invocation_context", ":vela_bin_stream", "//executorch/runtime/core:core", "fbsource//third-party/ethos-u-core-driver:core_driver_headers_only", diff --git a/backends/arm/runtime/tests/ethos-u/CMakeLists.txt b/backends/arm/runtime/tests/ethos-u/CMakeLists.txt index cf3ca5969c1..5a2ee4240e9 100644 --- a/backends/arm/runtime/tests/ethos-u/CMakeLists.txt +++ b/backends/arm/runtime/tests/ethos-u/CMakeLists.txt @@ -82,20 +82,57 @@ set_tests_properties( ) find_package(Threads REQUIRED) +add_library( + ethosu_execution_stats_test_library STATIC + "${EXECUTORCH_ROOT}/backends/arm/runtime/EthosUExecutionStats.cpp" +) +target_include_directories( + ethosu_execution_stats_test_library + PRIVATE + "${EXECUTORCH_ROOT}/.." + "${EXECUTORCH_ROOT}/examples/arm/arm-scratch/ethos-u/core_software/core_driver/include" +) + add_executable( ethosu_execution_stats_test - "${EXECUTORCH_ROOT}/backends/arm/runtime/EthosUExecutionStats.cpp" + "${EXECUTORCH_ROOT}/backends/arm/test/ethosu_pmu_test_stubs.cpp" "${EXECUTORCH_ROOT}/backends/arm/test/ethosu_execution_stats_test.cpp" ) target_include_directories( - ethosu_execution_stats_test PRIVATE "${EXECUTORCH_ROOT}/.." + ethosu_execution_stats_test + PRIVATE + "${EXECUTORCH_ROOT}/.." + "${EXECUTORCH_ROOT}/examples/arm/arm-scratch/ethos-u/core_software/core_driver/include" +) +target_link_libraries( + ethosu_execution_stats_test PRIVATE ethosu_execution_stats_test_library + Threads::Threads ) -target_link_libraries(ethosu_execution_stats_test PRIVATE Threads::Threads) add_test(NAME ethosu_execution_stats COMMAND ethosu_execution_stats_test) set_tests_properties( ethosu_execution_stats PROPERTIES LABELS "arm;ethos-u;runtime" ) +add_executable( + ethosu_pmu_callbacks_test + "${EXECUTORCH_ROOT}/backends/arm/test/ethosu_pmu_test_stubs.cpp" + "${EXECUTORCH_ROOT}/backends/arm/test/ethosu_pmu_callbacks_test.cpp" +) +target_include_directories( + ethosu_pmu_callbacks_test + PRIVATE + "${EXECUTORCH_ROOT}/.." + "${EXECUTORCH_ROOT}/examples/arm/arm-scratch/ethos-u/core_software/core_driver/include" +) +target_link_libraries( + ethosu_pmu_callbacks_test PRIVATE ethosu_execution_stats_test_library + Threads::Threads +) +add_test(NAME ethosu_pmu_callbacks COMMAND ethosu_pmu_callbacks_test) +set_tests_properties( + ethosu_pmu_callbacks PROPERTIES LABELS "arm;ethos-u;runtime" +) + add_test( NAME memory_allocation_baseline COMMAND "${_ethos_u_runtime_tests_dir}/test_memory_allocation_baseline.sh" diff --git a/backends/arm/test/ethosu_pmu_callbacks_test.cpp b/backends/arm/test/ethosu_pmu_callbacks_test.cpp new file mode 100644 index 00000000000..f984be616c7 --- /dev/null +++ b/backends/arm/test/ethosu_pmu_callbacks_test.cpp @@ -0,0 +1,116 @@ +/* + * Copyright 2026 Arm Limited and/or its affiliates. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include +#include + +#include +#include + +#include "ethosu_pmu_test_stubs.h" + +#include +#include +#include + +#define CHECK(expr) \ + do { \ + if (!(expr)) { \ + std::fprintf(stderr, "check failed at line %d: %s\n", __LINE__, #expr); \ + std::abort(); \ + } \ + } while (false) + +using namespace executorch::backends::arm; + +extern "C" { +void ethosu_inference_begin(ethosu_driver*, void*); +void ethosu_inference_end(ethosu_driver*, void*); +} + +int main() { + EthosUExecutionRecord records[2]; + EthosUStatsBuffer buffer{records, 2}; + CHECK(set_ethosu_stats_buffer(&buffer) == nullptr); + CHECK(executorch::backends::arm::ethosu_execution_metrics_enabled()); + + MockPmu u55_pmu; + ethosu_device_config u55_config{&u55_pmu}; + ethosu_driver u55{}; + u55.dev.config = &u55_config; + u55.dev.reg = reinterpret_cast(0x40000000); + u55.dev.caps = {0, 7, 0}; + u55.job.result = ETHOSU_JOB_RESULT_OK; + + MockPmu u85_pmu; + ethosu_device_config u85_config{&u85_pmu}; + ethosu_driver u85{}; + u85.dev.config = &u85_config; + u85.dev.reg = reinterpret_cast(0x50000000); + u85.dev.caps = {2, 8, 0}; + u85.job.result = ETHOSU_JOB_RESULT_ERROR; + + auto invoke = [&buffer](ethosu_driver* driver, uint64_t handle) { + EthosUInvocationContext context{}; + context.delegate_handle = handle; + ethosu_inference_begin(driver, &context); + CHECK(context.buffer == &buffer); + CHECK(pmu(driver).enabled); + pmu(driver).cycles = 100 + driver->dev.caps.product; + pmu(driver).counts[0] = 42 + driver->dev.caps.product; + pmu(driver).overflow = 1; + ethosu_inference_end(driver, &context); + CHECK(!pmu(driver).enabled); + }; + + std::thread first([&] { invoke(&u55, 10); }); + std::thread second([&] { invoke(&u85, 20); }); + first.join(); + second.join(); + + CHECK(buffer.size == 2); + CHECK(!buffer.overflow); + CHECK(observer_begins == 2); + CHECK(observer_ends == 2); + for (size_t index = 0; index < buffer.size; ++index) { + const auto& record = records[index]; + CHECK(record.stats.platform == EthosUPlatform::CoreDriver); + CHECK(record.stats.counter_count == (record.stats.product == 2 ? 7u : 4u)); + CHECK(record.stats.counters[0].overflow); + CHECK(record.stats.counters[0].value == 42 + record.stats.product); + if (record.stats.product == 2) { + CHECK(record.delegate_handle == 20); + CHECK(record.stats.device_identifier == 0x50000000); + CHECK(record.stats.counters[5].event_id == ETHOSU_PMU_MAC_ACTIVE); + CHECK(!record.success); + } else { + CHECK(record.delegate_handle == 10); + CHECK(record.stats.device_identifier == 0x40000000); + CHECK(record.stats.counters[3].event_id == ETHOSU_PMU_NPU_IDLE); + CHECK(record.success); + } + } + + MockPmu unknown_pmu; + ethosu_device_config unknown_config{&unknown_pmu}; + ethosu_driver unknown{}; + unknown.dev.config = &unknown_config; + unknown.dev.caps = {99, 8, 0}; + EthosUInvocationContext reused_context{}; + reused_context.counter_count = 32; + ethosu_inference_begin(&unknown, &reused_context); + CHECK(reused_context.buffer == &buffer); + CHECK(reused_context.counter_count == 0); + CHECK(!unknown_pmu.enabled); + ethosu_inference_end(&unknown, &reused_context); + CHECK(buffer.size == 2); + CHECK(observer_begins == 3); + CHECK(observer_ends == 3); + + CHECK(set_ethosu_stats_buffer(nullptr) == &buffer); + std::puts("Ethos-U PMU callbacks: PASS"); +} diff --git a/backends/arm/test/ethosu_pmu_test_stubs.cpp b/backends/arm/test/ethosu_pmu_test_stubs.cpp new file mode 100644 index 00000000000..144a45e08e6 --- /dev/null +++ b/backends/arm/test/ethosu_pmu_test_stubs.cpp @@ -0,0 +1,92 @@ +/* + * Copyright 2026 Arm Limited and/or its affiliates. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include "ethosu_pmu_test_stubs.h" + +#include +#include + +MockPmu& pmu(ethosu_driver* driver) { + return *static_cast(driver->dev.config->config); +} + +std::atomic observer_begins{0}; +std::atomic observer_ends{0}; + +extern "C" { +// Match the driver's weak hooks so the test verifies archive override behavior. +void __attribute__((weak)) ethosu_inference_begin(ethosu_driver*, void*) {} +void __attribute__((weak)) ethosu_inference_end(ethosu_driver*, void*) {} + +void EthosUBackend_npu_begin() { + ++observer_begins; +} + +void EthosUBackend_npu_end() { + ++observer_ends; +} + +void ETHOSU_PMU_Enable(ethosu_driver* driver) { + pmu(driver).enabled = true; +} + +void ETHOSU_PMU_Disable(ethosu_driver* driver) { + pmu(driver).enabled = false; +} + +uint32_t ETHOSU_PMU_Get_NumEventCountersForDrv(ethosu_driver* driver) { + return driver->dev.caps.product == 2 ? 8 : 4; +} + +void ETHOSU_PMU_Set_EVTYPER( + ethosu_driver* driver, + uint32_t slot, + ethosu_pmu_event_type event) { + pmu(driver).events[slot] = event; +} + +ethosu_pmu_event_type ETHOSU_PMU_Get_EVTYPER( + ethosu_driver* driver, + uint32_t slot) { + return pmu(driver).events[slot]; +} + +void ETHOSU_PMU_PMCCNTR_CFG_Set_Stop_Event( + ethosu_driver*, + ethosu_pmu_event_type) {} +void ETHOSU_PMU_PMCCNTR_CFG_Set_Start_Event( + ethosu_driver*, + ethosu_pmu_event_type) {} + +void ETHOSU_PMU_CNTR_Enable(ethosu_driver* driver, uint32_t mask) { + pmu(driver).enabled_mask = mask; +} + +void ETHOSU_PMU_CYCCNT_Reset(ethosu_driver* driver) { + pmu(driver).cycles = 0; +} + +void ETHOSU_PMU_EVCNTR_ALL_Reset(ethosu_driver* driver) { + std::fill(std::begin(pmu(driver).counts), std::end(pmu(driver).counts), 0); +} + +void ETHOSU_PMU_Set_CNTR_OVS(ethosu_driver* driver, uint32_t mask) { + pmu(driver).overflow &= ~mask; +} + +uint32_t ETHOSU_PMU_Get_CNTR_OVS(ethosu_driver* driver) { + return pmu(driver).overflow; +} + +uint64_t ETHOSU_PMU_Get_CCNTR(ethosu_driver* driver) { + return pmu(driver).cycles; +} + +uint32_t ETHOSU_PMU_Get_EVCNTR(ethosu_driver* driver, uint32_t slot) { + return pmu(driver).counts[slot]; +} +} diff --git a/backends/arm/test/ethosu_pmu_test_stubs.h b/backends/arm/test/ethosu_pmu_test_stubs.h new file mode 100644 index 00000000000..70045a88816 --- /dev/null +++ b/backends/arm/test/ethosu_pmu_test_stubs.h @@ -0,0 +1,25 @@ +/* + * Copyright 2026 Arm Limited and/or its affiliates. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ +#pragma once + +#include +#include + +#include + +struct MockPmu { + ethosu_pmu_event_type events[8]{}; + uint32_t counts[8]{}; + uint64_t cycles = 0; + uint32_t overflow = 0; + uint32_t enabled_mask = 0; + bool enabled = false; +}; + +MockPmu& pmu(ethosu_driver* driver); +extern std::atomic observer_begins; +extern std::atomic observer_ends; diff --git a/backends/arm/test/test_arm_backend.sh b/backends/arm/test/test_arm_backend.sh index ceea63ba8e1..e2ff9d85b29 100755 --- a/backends/arm/test/test_arm_backend.sh +++ b/backends/arm/test/test_arm_backend.sh @@ -287,6 +287,9 @@ test_run_ethos_u55() { examples/arm/run.sh --et_build_root=arm_test/test_run --target=ethos-u55-128 --model_name=examples/arm/example_modules/add.py examples/arm/run.sh --et_build_root=arm_test/test_run --target=ethos-u55-128 --model_name=examples/arm/example_modules/add.py --bundleio + # Verify the runner links and runs without ethosu_execution_stats. + examples/arm/run.sh --et_build_root=arm_test/test_run_no_stats --target=ethos-u55-128 --model_name=add --extra_build_flags="-DEXECUTORCH_ARM_ETHOSU_PMU_STATS=OFF" + echo "${TEST_SUITE_NAME}: Test target Ethos-U65" examples/arm/run.sh --et_build_root=arm_test/test_run --target=ethos-u65-256 --model_name=examples/arm/example_modules/add.py examples/arm/run.sh --et_build_root=arm_test/test_run --target=ethos-u65-256 --model_name=examples/arm/example_modules/add.py --bundleio diff --git a/backends/cortex_m/test/build_test_runner.sh b/backends/cortex_m/test/build_test_runner.sh index d18463059ef..665472234c1 100755 --- a/backends/cortex_m/test/build_test_runner.sh +++ b/backends/cortex_m/test/build_test_runner.sh @@ -83,4 +83,4 @@ ops_list=( cortex_m::quantized_batch_matmul.out ) -${build_executor_runner} --pte=semihosting --bundleio --target="${target}" --output="${build_root_test_dir}" --select_ops_list="$(join_by_comma "${ops_list[@]}")" --extra_build_flags="-DCORTEX_M_ENABLE_RUNTIME_CHECKS=ON -DET_ATOL=5.0 -DET_RTOL=1.0 -DET_ARM_BAREMETAL_SCRATCH_TEMP_ALLOCATOR_POOL_SIZE=0" +${build_executor_runner} --pte=semihosting --bundleio --target="${target}" --output="${build_root_test_dir}" --select_ops_list="$(join_by_comma "${ops_list[@]}")" --extra_build_flags="-DCORTEX_M_ENABLE_RUNTIME_CHECKS=ON -DET_ATOL=5.0 -DET_RTOL=1.0 -DET_ARM_BAREMETAL_SCRATCH_TEMP_ALLOCATOR_POOL_SIZE=0 -DEXECUTORCH_ARM_ETHOSU_PMU_STATS=OFF" diff --git a/examples/arm/executor_runner/CMakeLists.txt b/examples/arm/executor_runner/CMakeLists.txt index 63d49a1ba1e..62832afd7bb 100644 --- a/examples/arm/executor_runner/CMakeLists.txt +++ b/examples/arm/executor_runner/CMakeLists.txt @@ -57,6 +57,10 @@ endif() option(ET_LOG_DUMP_INPUT "Dump input in log" OFF) option(ET_LOG_DUMP_OUTPUT "Dump output in log" ON) +option(EXECUTORCH_ARM_ETHOSU_PMU_STATS + "Link Ethos-U PMU statistics into the Arm executor runner" + ${EXECUTORCH_BUILD_ARM_BAREMETAL} +) option(ET_ARM_ETHOSU_PER_DELEGATE_PROFILING "Report Ethos-U PMU statistics per delegate" OFF ) @@ -67,7 +71,6 @@ set(ET_ARM_ETHOSU_MAX_PROFILED_DELEGATES "16" CACHE STRING "Maximum delegates tracked by per-delegate profiling" ) - option(ET_BUNDLE_IO "Set to compile in BundleIO support" OFF) set(BUNDLED_PROGRAM_LIBRARY_DIR "" @@ -111,6 +114,16 @@ set(ET_ARM_BAREMETAL_PLANNED_FAST_MEMORY_SIZE include(${EXECUTORCH_ROOT}/backends/arm/cmake/ArmRunnerUtils.cmake) +if(EXECUTORCH_ARM_ETHOSU_PMU_STATS) + arm_runner_check_lock_free_atomics(_ethosu_pmu_stats_atomics) + if(NOT _ethosu_pmu_stats_atomics) + message( + FATAL_ERROR + "Ethos-U PMU stats require lock-free byte and pointer atomics, which this target/toolchain does not provide. Disable them with -DEXECUTORCH_ARM_ETHOSU_PMU_STATS=OFF." + ) + endif() +endif() + arm_runner_define_cache_options() arm_runner_validate_model_source(ALLOW_SEMIHOSTING) arm_runner_require_python() @@ -352,6 +365,12 @@ if(ET_NUM_INFERENCES) endif() if(ET_ARM_ETHOSU_PER_DELEGATE_PROFILING) + if(NOT EXECUTORCH_ARM_ETHOSU_PMU_STATS) + message( + FATAL_ERROR + "ET_ARM_ETHOSU_PER_DELEGATE_PROFILING requires EXECUTORCH_ARM_ETHOSU_PMU_STATS" + ) + endif() if(NOT ET_ARM_ETHOSU_MAX_PROFILED_DELEGATES MATCHES "^[1-9][0-9]*$") message( FATAL_ERROR @@ -369,6 +388,19 @@ if(ET_ARM_ETHOSU_PER_DELEGATE_PROFILING) ) endif() +if(EXECUTORCH_ARM_ETHOSU_PMU_STATS) + if(NOT TARGET ethosu_execution_stats) + message( + FATAL_ERROR + "EXECUTORCH_ARM_ETHOSU_PMU_STATS is currently only supported by the bare-metal Ethos-U backend" + ) + endif() + target_link_libraries(arm_executor_runner PRIVATE ethosu_execution_stats) + target_compile_definitions( + arm_executor_runner PRIVATE ET_ARM_ETHOSU_PMU_STATS + ) +endif() + if(ET_ARM_ETHOSU_PROFILE_IO_COPIES) target_compile_definitions( arm_executor_runner PRIVATE ET_ARM_ETHOSU_PROFILE_IO_COPIES diff --git a/examples/arm/executor_runner/arm_executor_runner.cpp b/examples/arm/executor_runner/arm_executor_runner.cpp index 29940ba47f9..f37844ced65 100644 --- a/examples/arm/executor_runner/arm_executor_runner.cpp +++ b/examples/arm/executor_runner/arm_executor_runner.cpp @@ -1365,6 +1365,7 @@ bool run_model( } } status = ctx.method.value()->execute(); + ConsumeMeasurements(); if (status != Error::Ok) { break; } @@ -1433,6 +1434,7 @@ bool run_model_server( StartMeasurements(); status = ctx.method.value()->execute(); + ConsumeMeasurements(); StopMeasurements(1); ET_CHECK_MSG( status == Error::Ok, diff --git a/examples/arm/executor_runner/arm_perf_monitor.cpp b/examples/arm/executor_runner/arm_perf_monitor.cpp index 9e37df3bb56..c45869b11a4 100644 --- a/examples/arm/executor_runner/arm_perf_monitor.cpp +++ b/examples/arm/executor_runner/arm_perf_monitor.cpp @@ -12,9 +12,11 @@ using printf_size_t = unsigned long; #ifdef ETHOSU -#include +#include #include -#include +#if defined(ET_ARM_ETHOSU_PMU_STATS) +#include +#endif namespace { @@ -27,21 +29,86 @@ static inline uint64_t arm_pmu_cycles() { #endif } -#if defined(ETHOSU55) || defined(ETHOSU65) -const uint32_t ethosu_pmuCountersUsed = 4; -#elif defined(ETHOSU85) -const uint32_t ethosu_pmuCountersUsed = 7; -#else -#error No NPU target defined -#endif - uint32_t ethosu_delegation_count = 0; uint64_t ethosu_ArmCycleCountStart = 0; uint64_t ethosu_ArmBackendExecuteCycleCountStart = 0; uint64_t ethosu_ArmBackendExecuteCycleCount = 0; uint64_t ethosu_ArmWhenNPURunCycleCountStart = 0; uint64_t ethosu_ArmWhenNPURunCycleCount = 0; + +#if defined(ET_ARM_ETHOSU_PMU_STATS) +using executorch::backends::arm::EthosUExecutionRecord; +using executorch::backends::arm::EthosUExecutionStats; +using executorch::backends::arm::EthosUStatsBuffer; + +constexpr size_t kMaxPmuRecordsPerInference = 16; +constexpr size_t kMaxProfiledDevices = 1; + +std::array ethosu_pmuRecords; +EthosUStatsBuffer ethosu_pmuBuffer{ + ethosu_pmuRecords.data(), + ethosu_pmuRecords.size()}; + +struct DevicePmuTotals { + EthosUExecutionStats stats{}; + uint64_t invocations = 0; + uint64_t failures = 0; +}; +std::array ethosu_deviceTotals; +size_t ethosu_deviceCount = 0; +bool ethosu_deviceCapacityExceeded = false; +bool ethosu_recordCapacityExceeded = false; uint64_t ethosu_pmuCycleCount = 0; +#if defined(ET_ARM_ETHOSU_PER_DELEGATE_PROFILING) +struct DelegatePmuTotals { + uintptr_t handle = 0; + DevicePmuTotals totals; +}; +std::array + ethosu_delegatePmuTotals; +size_t ethosu_delegatePmuCount = 0; +bool ethosu_delegatePmuCapacityExceeded = false; +#endif + +bool same_layout( + const EthosUExecutionStats& left, + const EthosUExecutionStats& right) { + if (left.device_identifier != right.device_identifier || + left.product != right.product || left.platform != right.platform || + left.counter_count != right.counter_count) { + return false; + } + for (uint32_t slot = 0; slot < left.counter_count; ++slot) { + if (left.counters[slot].event_id != right.counters[slot].event_id) { + return false; + } + } + return true; +} + +void accumulate_pmu_totals( + DevicePmuTotals& totals, + const EthosUExecutionRecord& record) { + if (totals.invocations == 0) { + totals.stats = record.stats; + totals.stats.cycle_count = 0; + totals.stats.cycle_overflow = false; + for (uint32_t slot = 0; slot < totals.stats.counter_count; ++slot) { + totals.stats.counters[slot].value = 0; + totals.stats.counters[slot].overflow = false; + } + } + ++totals.invocations; + totals.failures += !record.success; + totals.stats.cycle_count += record.stats.cycle_count; + totals.stats.cycle_overflow |= record.stats.cycle_overflow; + for (uint32_t slot = 0; slot < record.stats.counter_count; ++slot) { + totals.stats.counters[slot].value += record.stats.counters[slot].value; + totals.stats.counters[slot].overflow |= + record.stats.counters[slot].overflow; + } +} +#endif #if defined(ET_ARM_ETHOSU_PROFILE_IO_COPIES) struct IOCopyStats { uint64_t calls = 0; @@ -56,7 +123,6 @@ struct DelegateStats { uint64_t backend_invocations = 0; uint64_t npu_invocations = 0; uint64_t pmu_cycles = 0; - std::array pmu_events{}; #if defined(ET_ARM_ETHOSU_PROFILE_IO_COPIES) IOCopyStats input_copies; IOCopyStats output_copies; @@ -84,12 +150,6 @@ DelegateStats* get_delegate_stats(const void* handle) { return &stats; } #endif -std::array ethosu_pmuEventCounts = {0}; - -// ethosu_pmuCountersUsed should match numbers of counters setup in -// ethosu_inference_begin() and not be more then the HW supports -static_assert(ETHOSU_PMU_NCOUNTERS >= ethosu_pmuCountersUsed); - } // namespace extern "C" { @@ -131,82 +191,14 @@ void EthosUBackend_delegate_end() { } #endif -// Callback invoked at start of NPU execution -void ethosu_inference_begin(struct ethosu_driver* drv, void*) { - // Enable PMU - ETHOSU_PMU_Enable(drv); - ETHOSU_PMU_PMCCNTR_CFG_Set_Stop_Event(drv, ETHOSU_PMU_NPU_IDLE); - ETHOSU_PMU_PMCCNTR_CFG_Set_Start_Event(drv, ETHOSU_PMU_NPU_ACTIVE); - - // Setup 4 counters -#if defined(ETHOSU55) || defined(ETHOSU65) - ETHOSU_PMU_Set_EVTYPER(drv, 0, ETHOSU_PMU_AXI0_RD_DATA_BEAT_RECEIVED); - ETHOSU_PMU_Set_EVTYPER(drv, 1, ETHOSU_PMU_AXI1_RD_DATA_BEAT_RECEIVED); - ETHOSU_PMU_Set_EVTYPER(drv, 2, ETHOSU_PMU_AXI0_WR_DATA_BEAT_WRITTEN); - ETHOSU_PMU_Set_EVTYPER(drv, 3, ETHOSU_PMU_NPU_IDLE); - // Enable the 4 counters - ETHOSU_PMU_CNTR_Enable( - drv, - ETHOSU_PMU_CNT1_Msk | ETHOSU_PMU_CNT2_Msk | ETHOSU_PMU_CNT3_Msk | - ETHOSU_PMU_CNT4_Msk); -#elif defined(ETHOSU85) - ETHOSU_PMU_Set_EVTYPER(drv, 0, ETHOSU_PMU_SRAM_RD_DATA_BEAT_RECEIVED); - ETHOSU_PMU_Set_EVTYPER(drv, 1, ETHOSU_PMU_SRAM_WR_DATA_BEAT_WRITTEN); - ETHOSU_PMU_Set_EVTYPER(drv, 2, ETHOSU_PMU_EXT_RD_DATA_BEAT_RECEIVED); - ETHOSU_PMU_Set_EVTYPER(drv, 3, ETHOSU_PMU_EXT_WR_DATA_BEAT_WRITTEN); - ETHOSU_PMU_Set_EVTYPER(drv, 4, ETHOSU_PMU_NPU_IDLE); - ETHOSU_PMU_Set_EVTYPER(drv, 5, ETHOSU_PMU_MAC_ACTIVE); - ETHOSU_PMU_Set_EVTYPER(drv, 6, ETHOSU_PMU_WD_ACTIVE); - // Enable the 7 counters - ETHOSU_PMU_CNTR_Enable( - drv, - ETHOSU_PMU_CNT1_Msk | ETHOSU_PMU_CNT2_Msk | ETHOSU_PMU_CNT3_Msk | - ETHOSU_PMU_CNT4_Msk | ETHOSU_PMU_CNT5_Msk | ETHOSU_PMU_CNT6_Msk | - ETHOSU_PMU_CNT7_Msk); -#else -#error No NPU target defined -#endif - - ETHOSU_PMU_CNTR_Enable(drv, ETHOSU_PMU_CCNT_Msk); - ETHOSU_PMU_CYCCNT_Reset(drv); - - // Reset all counters - ETHOSU_PMU_EVCNTR_ALL_Reset(drv); - - // Save Cortex-M cycle clock to calculate total CPU cycles used in - // ethosu_inference_end() +void EthosUBackend_npu_begin() { ethosu_ArmWhenNPURunCycleCountStart = arm_pmu_cycles(); } -// Callback invoked at end of NPU execution -void ethosu_inference_end(struct ethosu_driver* drv, void*) { - ethosu_delegation_count++; -#if defined(ET_ARM_ETHOSU_PER_DELEGATE_PROFILING) - const uint64_t pmu_cycles = ETHOSU_PMU_Get_CCNTR(drv); - ethosu_pmuCycleCount += pmu_cycles; - if (ethosu_activeDelegate != nullptr) { - ethosu_activeDelegate->npu_invocations++; - ethosu_activeDelegate->pmu_cycles += pmu_cycles; - } -#else - ethosu_pmuCycleCount += ETHOSU_PMU_Get_CCNTR(drv); -#endif - - for (size_t i = 0; i < ethosu_pmuCountersUsed; i++) { -#if defined(ET_ARM_ETHOSU_PER_DELEGATE_PROFILING) - const uint64_t event_count = ETHOSU_PMU_Get_EVCNTR(drv, i); - ethosu_pmuEventCounts[i] += event_count; - if (ethosu_activeDelegate != nullptr) { - ethosu_activeDelegate->pmu_events[i] += event_count; - } -#else - ethosu_pmuEventCounts[i] += ETHOSU_PMU_Get_EVCNTR(drv, i); -#endif - } - ETHOSU_PMU_Disable(drv); - // Add Cortex-M cycle clock used during this NPU execution +void EthosUBackend_npu_end() { ethosu_ArmWhenNPURunCycleCount += - (arm_pmu_cycles() - ethosu_ArmWhenNPURunCycleCountStart); + arm_pmu_cycles() - ethosu_ArmWhenNPURunCycleCountStart; + ++ethosu_delegation_count; } // Callback invoked at start of ArmBackend::execute() @@ -224,6 +216,57 @@ void EthosUBackend_execute_end() { } } +void ConsumeMeasurements() { +#if defined(ET_ARM_ETHOSU_PMU_STATS) + for (size_t index = 0; index < ethosu_pmuBuffer.size; ++index) { + const EthosUExecutionRecord& record = ethosu_pmuBuffer.records[index]; + ethosu_pmuCycleCount += record.stats.cycle_count; +#if defined(ET_ARM_ETHOSU_PER_DELEGATE_PROFILING) + if (auto* delegate = get_delegate_stats( + reinterpret_cast(record.delegate_handle))) { + ++delegate->npu_invocations; + delegate->pmu_cycles += record.stats.cycle_count; + } + DelegatePmuTotals* delegate_totals = nullptr; + for (size_t entry = 0; entry < ethosu_delegatePmuCount; ++entry) { + auto& candidate = ethosu_delegatePmuTotals[entry]; + if (candidate.handle == record.delegate_handle && + same_layout(candidate.totals.stats, record.stats)) { + delegate_totals = &candidate; + break; + } + } + if (delegate_totals == nullptr && + ethosu_delegatePmuCount < ethosu_delegatePmuTotals.size()) { + delegate_totals = ðosu_delegatePmuTotals[ethosu_delegatePmuCount++]; + delegate_totals->handle = record.delegate_handle; + } + if (delegate_totals != nullptr) { + accumulate_pmu_totals(delegate_totals->totals, record); + } else { + ethosu_delegatePmuCapacityExceeded = true; + } +#endif + DevicePmuTotals* totals = nullptr; + for (size_t device = 0; device < ethosu_deviceCount; ++device) { + if (same_layout(ethosu_deviceTotals[device].stats, record.stats)) { + totals = ðosu_deviceTotals[device]; + break; + } + } + if (totals == nullptr && ethosu_deviceCount < ethosu_deviceTotals.size()) { + totals = ðosu_deviceTotals[ethosu_deviceCount++]; + } + if (totals == nullptr) { + ethosu_deviceCapacityExceeded = true; + continue; + } + accumulate_pmu_totals(*totals, record); + } + ethosu_recordCapacityExceeded |= ethosu_pmuBuffer.reset(); +#endif +} + void StartMeasurements() { #if defined(__ARM_ARCH_8_1M_MAIN__) // StopMeasurements() disables the cycle counter after each measurement. @@ -233,8 +276,22 @@ void StartMeasurements() { #endif ethosu_delegation_count = 0; ethosu_ArmBackendExecuteCycleCount = 0; + ethosu_ArmWhenNPURunCycleCountStart = 0; ethosu_ArmWhenNPURunCycleCount = 0; +#if defined(ET_ARM_ETHOSU_PMU_STATS) + ethosu_deviceTotals = {}; + ethosu_deviceCount = 0; + ethosu_deviceCapacityExceeded = false; + ethosu_recordCapacityExceeded = false; ethosu_pmuCycleCount = 0; +#if defined(ET_ARM_ETHOSU_PER_DELEGATE_PROFILING) + ethosu_delegatePmuTotals = {}; + ethosu_delegatePmuCount = 0; + ethosu_delegatePmuCapacityExceeded = false; +#endif + ethosu_pmuBuffer.reset(); + executorch::backends::arm::set_ethosu_stats_buffer(ðosu_pmuBuffer); +#endif #if defined(ET_ARM_ETHOSU_PROFILE_IO_COPIES) ethosu_inputCopyStats = {}; ethosu_outputCopyStats = {}; @@ -246,9 +303,6 @@ void StartMeasurements() { ethosu_delegateCapacityExceeded = false; #endif - for (size_t i = 0; i < ethosu_pmuCountersUsed; i++) { - ethosu_pmuEventCounts[i] = 0; - } ethosu_ArmCycleCountStart = arm_pmu_cycles(); } @@ -262,6 +316,12 @@ void StopMeasurements(size_t num_inferences) { ET_LOG(Error, "Cannot report measurements without completed inferences"); return; } + if (!executorch::backends::arm::ethosu_execution_metrics_enabled()) { + ET_LOG( + Info, + "WARNING: Ethos-U execution metrics are not linked; NPU delegations, " + "wait cycles, and CPU/NPU ratios are incorrect"); + } uint64_t cycle_count = arm_pmu_cycles() - ethosu_ArmCycleCountStart; // Number of comand streams handled by the NPU @@ -351,21 +411,50 @@ void StopMeasurements(size_t num_inferences) { ethosu_ArmWhenNPURunCycleCount, (double)ethosu_ArmWhenNPURunCycleCount / num_inferences); +#if defined(ET_ARM_ETHOSU_PMU_STATS) ET_LOG(Info, "Ethos-U PMU report:"); + for (size_t device = 0; device < ethosu_deviceCount; ++device) { + const DevicePmuTotals& totals = ethosu_deviceTotals[device]; + const EthosUExecutionStats& stats = totals.stats; + ET_LOG( + Info, + "Ethos-U NPU 0x%" PRIxPTR ": product %" PRIu32 ", invocations %" PRIu64 + ", failures %" PRIu64, + stats.device_identifier, + stats.product, + totals.invocations, + totals.failures); + ET_LOG( + Info, + "Ethos-U NPU 0x%" PRIxPTR " PMU cycles: %" PRIu64 " (overflow: %u)", + stats.device_identifier, + stats.cycle_count, + static_cast(stats.cycle_overflow)); + for (uint32_t slot = 0; slot < stats.counter_count; ++slot) { + const auto& counter = stats.counters[slot]; + ET_LOG( + Info, + "Ethos-U NPU 0x%" PRIxPTR " PMU counter %" PRIu32 " event %" PRIu32 + ": %" PRIu64 " (overflow: %u)", + stats.device_identifier, + slot, + counter.event_id, + counter.value, + static_cast(counter.overflow)); + } + } ET_LOG( Info, "ethosu_pmu_cycle_cntr : %" PRIu64 " (%.2f per inference)", ethosu_pmuCycleCount, (double)ethosu_pmuCycleCount / num_inferences); - - for (size_t i = 0; i < ethosu_pmuCountersUsed; i++) { - ET_LOG( - Info, - "ethosu_pmu_cntr%lu : %" PRIu64 " (%.2f per inference)", - static_cast(i), - ethosu_pmuEventCounts[i], - (double)ethosu_pmuEventCounts[i] / num_inferences); + if (ethosu_recordCapacityExceeded) { + ET_LOG(Error, "Ethos-U PMU record buffer overflowed; samples were lost"); + } + if (ethosu_deviceCapacityExceeded) { + ET_LOG(Error, "Ethos-U PMU device/layout capacity exceeded"); } +#endif #if defined(ET_ARM_ETHOSU_PER_DELEGATE_PROFILING) ET_LOG(Info, "Ethos-U per-delegate PMU report:"); for (size_t delegate_id = 0; delegate_id < ethosu_delegateCount; @@ -383,13 +472,25 @@ void StopMeasurements(size_t num_inferences) { "Ethos-U delegate %lu PMU cycles: %" PRIu64, static_cast(delegate_id), stats.pmu_cycles); - for (size_t event = 0; event < ethosu_pmuCountersUsed; ++event) { - ET_LOG( - Info, - "Ethos-U delegate %lu PMU counter %lu: %" PRIu64, - static_cast(delegate_id), - static_cast(event), - stats.pmu_events[event]); + for (size_t entry = 0; entry < ethosu_delegatePmuCount; ++entry) { + const DelegatePmuTotals& pmu_totals = ethosu_delegatePmuTotals[entry]; + if (pmu_totals.handle != reinterpret_cast(stats.handle)) { + continue; + } + const EthosUExecutionStats& pmu = pmu_totals.totals.stats; + for (uint32_t slot = 0; slot < pmu.counter_count; ++slot) { + const auto& counter = pmu.counters[slot]; + ET_LOG( + Info, + "Ethos-U delegate %lu NPU 0x%" PRIxPTR " PMU counter %" PRIu32 + " event %" PRIu32 ": %" PRIu64 " (overflow: %u)", + static_cast(delegate_id), + pmu.device_identifier, + slot, + counter.event_id, + counter.value, + static_cast(counter.overflow)); + } } #if defined(ET_ARM_ETHOSU_PROFILE_IO_COPIES) ET_LOG( @@ -413,17 +514,9 @@ void StopMeasurements(size_t num_inferences) { "Ethos-U per-delegate profiling exceeded its capacity of %lu delegates", static_cast(ethosu_delegateStats.size())); } -#endif -#if defined(ETHOSU55) || defined(ETHOSU65) - ET_LOG( - Info, - "Ethos-U PMU Events:[ETHOSU_PMU_AXI0_RD_DATA_BEAT_RECEIVED, ETHOSU_PMU_AXI1_RD_DATA_BEAT_RECEIVED, ETHOSU_PMU_AXI0_WR_DATA_BEAT_WRITTEN, ETHOSU_PMU_NPU_IDLE]"); -#elif defined(ETHOSU85) - ET_LOG( - Info, - "Ethos-U PMU Events:[ETHOSU_PMU_SRAM_RD_DATA_BEAT_RECEIVED, ETHOSU_PMU_SRAM_WR_DATA_BEAT_WRITTEN, ETHOSU_PMU_EXT_RD_DATA_BEAT_RECEIVED, ETHOSU_PMU_EXT_WR_DATA_BEAT_WRITTEN, ETHOSU_PMU_NPU_IDLE, ETHOSU_PMU_MAC_ACTIVE, ETHOSU_PMU_WD_ACTIVE]"); -#else -#error No NPU target defined + if (ethosu_delegatePmuCapacityExceeded) { + ET_LOG(Error, "Ethos-U per-delegate PMU device/layout capacity exceeded"); + } #endif } @@ -431,6 +524,9 @@ void StopMeasurements(size_t num_inferences) { // cppcheck-suppress unusedFunction void StartMeasurements() {} +// cppcheck-suppress unusedFunction +void ConsumeMeasurements() {} + // cppcheck-suppress unusedFunction void StopMeasurements(size_t num_inferences) { (void)num_inferences; diff --git a/examples/arm/executor_runner/arm_perf_monitor.h b/examples/arm/executor_runner/arm_perf_monitor.h index ef37e25fd6a..1a6173af240 100644 --- a/examples/arm/executor_runner/arm_perf_monitor.h +++ b/examples/arm/executor_runner/arm_perf_monitor.h @@ -9,4 +9,5 @@ #include void StartMeasurements(); +void ConsumeMeasurements(); void StopMeasurements(size_t num_inferences); diff --git a/examples/arm/executor_runner/standalone/CMakeLists.txt b/examples/arm/executor_runner/standalone/CMakeLists.txt index 963f8908259..01ae41c6a30 100644 --- a/examples/arm/executor_runner/standalone/CMakeLists.txt +++ b/examples/arm/executor_runner/standalone/CMakeLists.txt @@ -87,7 +87,6 @@ set(EXECUTORCH_SKIP_ARM_EXECUTOR_RUNNER ON CACHE BOOL "" FORCE ) - # examples/arm/executor_runner/CMakeLists.txt generates runner-specific # registration libraries based on the PTE or an explicit select-ops list. Avoid # feeding those cache entries into the top-level ExecuTorch configure, otherwise From a489dcac3b111c99fa0f6e40f5d14db6b2f1f62b Mon Sep 17 00:00:00 2001 From: Elena Zhelezina Date: Tue, 3 Nov 2026 12:38:12 +0000 Subject: [PATCH 006/128] Arm backend: Fix metadata provenance for DecomposeTopK (#24497) Fix for provenance metadata for DecomposeTopK. cc @digantdesai @freddan80 @per @zingo @oscarandersson8218 @mansnils @Sebastian-Larsson @robell @rascani Signed-off-by: Elena Zhelezina --- backends/arm/_passes/decompose_topk_pass.py | 29 +++++-- .../test/passes/test_decompose_topk_pass.py | 87 +++++++++++++++++++ 2 files changed, 111 insertions(+), 5 deletions(-) diff --git a/backends/arm/_passes/decompose_topk_pass.py b/backends/arm/_passes/decompose_topk_pass.py index 421e52ac0eb..3916bc5425e 100644 --- a/backends/arm/_passes/decompose_topk_pass.py +++ b/backends/arm/_passes/decompose_topk_pass.py @@ -219,6 +219,7 @@ def _build_topk_indices( graph: torch.fx.Graph, scores: torch.fx.Node, config: StaticTopKConfig, + source_node: torch.fx.Node, ) -> torch.fx.Node: """Build int32 indices using repeated ARGMAX and cumulative masking. @@ -226,6 +227,7 @@ def _build_topk_indices( graph (torch.fx.Graph): Graph with the insertion point set. scores (torch.fx.Node): Original score tensor of shape [T, E]. config (StaticTopKConfig): Validated static TopK parameters. + source_node (torch.fx.Node): Original TopK operator for provenance. Returns: torch.fx.Node: Int32 indices with shape [T, K]. @@ -240,29 +242,36 @@ def _build_topk_indices( exir_ops.edge.aten.arange.start_step, (0, config.experts, 1), {"dtype": torch.int32, "device": scores_tensor.device}, + from_node=source_node, ) expert_ids = create_node( graph, exir_ops.edge.aten.view_copy.default, (expert_ids, [1, config.experts]), + from_node=source_node, ) minus_inf = create_node( graph, exir_ops.edge.aten.full.default, ([1, 1], float("-inf")), {"dtype": scores_tensor.dtype, "device": scores_tensor.device}, + from_node=source_node, ) remaining = scores selected_indices: list[torch.fx.Node] = [] for step in range(config.k): # ARGMAX reduces [T, E] to int32 indices [T]. index = create_node( - graph, exir_ops.backend.tosa.ARGMAX.default, (remaining, 1) + graph, + exir_ops.backend.tosa.ARGMAX.default, + (remaining, 1), + from_node=source_node, ) index = create_node( graph, exir_ops.edge.aten.view_copy.default, (index, [config.tokens, 1]), + from_node=source_node, ) selected_indices.append(index) if step + 1 < config.k: @@ -270,20 +279,27 @@ def _build_topk_indices( # Compare expert IDs [1, E] with selected indices [T, 1] # to produce a mask [T, E]. mask = create_node( - graph, exir_ops.edge.aten.eq.Tensor, (expert_ids, index) + graph, + exir_ops.edge.aten.eq.Tensor, + (expert_ids, index), + from_node=source_node, ) # Mask selected scores in remaining [T, E] with -inf. remaining = create_node( graph, exir_ops.edge.aten.where.self, (mask, minus_inf, remaining), + from_node=source_node, ) if config.k == 1: indices = selected_indices[0] else: # Concatenate K selections of shape [T, 1] into [T, K]. indices = create_node( - graph, exir_ops.edge.aten.cat.default, (selected_indices, 1) + graph, + exir_ops.edge.aten.cat.default, + (selected_indices, 1), + from_node=source_node, ) return indices @@ -301,11 +317,14 @@ def call(self, graph_module: torch.fx.GraphModule) -> PassResult: raise RuntimeError("Delegated TopK requires prepared int32 indices.") scores = topk.args[0] if topk.args else topk.kwargs["self"] with graph.inserting_before(topk): - indices = self._build_topk_indices(graph, scores, config) + indices = self._build_topk_indices(graph, scores, config, topk) # Gather from scores [T, E] using indices [T, K] # to produce values [T, K]. values = create_node( - graph, exir_ops.edge.aten.gather.default, (scores, 1, indices) + graph, + exir_ops.edge.aten.gather.default, + (scores, 1, indices), + from_node=topk, ) # Redirect TopK getitem consumers to the new values and indices. for extraction in list(topk.users): diff --git a/backends/arm/test/passes/test_decompose_topk_pass.py b/backends/arm/test/passes/test_decompose_topk_pass.py index 2211a0ea9e5..8b455432fa1 100644 --- a/backends/arm/test/passes/test_decompose_topk_pass.py +++ b/backends/arm/test/passes/test_decompose_topk_pass.py @@ -61,3 +61,90 @@ def forward(self, scores): sentinel = next(n for n in nodes if n.target == exir_ops.edge.aten.full.default) assert sentinel.args[1] == float("-inf") assert sentinel.kwargs["dtype"] == dtype + + +@pytest.mark.parametrize("k", [1, 4]) +def test_decompose_topk_generated_nodes_preserve_provenance(k: int) -> None: + """Every replacement op must remain attributable to its TopK source. + + TopK has tuple-valued metadata, while its replacements are single tensors. + The pass must retain source information without leaking TopK tuple shapes or + its quantization parameters into the generated operations. + + """ + + class Model(torch.nn.Module): + def forward(self, scores: torch.Tensor): + values, indices = torch.topk(scores, k) + return values, indices.int() + + spec = TosaSpecification.create_from_string("TOSA-1.0+FP+INT") + ep = to_edge( + torch.export.export(Model(), (torch.randn(2, 8),)), + compile_config=EdgeCompileConfig(_check_ir_validity=False), + ).exported_program() + converted = ConvertInt64OutputOpsToInt32Pass( + convert_cast_ops=False, tosa_spec=spec + )(ep.graph_module) + assert converted is not None + gm = converted.graph_module + topk_nodes = [node for node in gm.graph.nodes if node.target in TOPK_OPS] + assert len(topk_nodes) == 1 + topk = topk_nodes[0] + + # Distinguish the TopK provenance from its input producer's provenance. + original_stack = {"topk_layer": ("topk_layer", torch.nn.Module)} + original_source = [("topk_layer.forward", torch.topk)] + topk.meta["nn_module_stack"] = original_stack + topk.meta["source_fn_stack"] = original_source + topk.meta["stack_trace"] = "originating TopK stack trace" + topk.meta["debug_handle"] = 987654321 + topk.meta["input_qparams"] = {0: "do not copy"} + topk.meta["output_qparams"] = {0: "do not copy"} + + with TosaLoweringContext(spec): + result = DecomposeTopKPass(spec)(gm) + assert result is not None + assert result.modified + + all_nodes = list(result.graph_module.graph.nodes) + assert not any(node.target in TOPK_OPS for node in all_nodes) + + # There are 3 generated nodes for K=1 (ARGMAX, view, gather), and + # 19 for K=4 (including expert ids, masking and concatenation). + generated = [ + node + for node in all_nodes + if node.op == "call_function" and node.meta.get("debug_handle") == 987654321 + ] + assert len(generated) == (3 if k == 1 else 19) + assert ( + sum(node.target == exir_ops.backend.tosa.ARGMAX.default for node in generated) + == k + ) + assert ( + sum(node.target == exir_ops.edge.aten.gather.default for node in generated) == 1 + ) + if k > 1: + assert ( + sum(node.target == exir_ops.edge.aten.where.self for node in generated) + == k - 1 + ) + + for node in generated: + assert node.meta.get("nn_module_stack") == original_stack, node.name + assert node.meta.get("source_fn_stack") == original_source, node.name + assert "originating TopK stack trace" in node.meta.get("stack_trace", "") + assert not node.meta.get("input_qparams"), node.name + assert not node.meta.get("output_qparams"), node.name + # ExportPass must recalculate the tensor-valued metadata: the original + # TopK node returns a tuple, but every generated op returns a tensor. + assert isinstance(node.meta.get("val"), torch.Tensor), node.name + + gather = next( + node for node in generated if node.target == exir_ops.edge.aten.gather.default + ) + assert tuple(gather.meta["val"].shape) == (2, k) + assert gather.meta["val"].dtype == torch.float32 + assert tuple(gather.args[2].meta["val"].shape) == (2, k) + assert gather.args[2].meta["val"].dtype == torch.int32 From a3528d99ebed7d6f11eb9e336679477354a1438f Mon Sep 17 00:00:00 2001 From: Adrian Lundell <36153706+AdrianLundell@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:03:38 +0100 Subject: [PATCH 007/128] Arm backend: Group adaptive average pooling output windows (#24498) Plan exact integer adaptive windows as shared pooling patches and recognize equivalent padded pools within target restrictions. Signed-off-by: Adrian Lundell --- .../decompose_adaptive_avg_pool2d_pass.py | 199 +++++++++++++----- .../arm/test/ops/test_adaptive_avg_pool2d.py | 14 +- ...test_decompose_adaptive_avg_pool2d_pass.py | 132 ++++++++++-- 3 files changed, 279 insertions(+), 66 deletions(-) diff --git a/backends/arm/_passes/decompose_adaptive_avg_pool2d_pass.py b/backends/arm/_passes/decompose_adaptive_avg_pool2d_pass.py index 137251503c4..d8de9452b97 100644 --- a/backends/arm/_passes/decompose_adaptive_avg_pool2d_pass.py +++ b/backends/arm/_passes/decompose_adaptive_avg_pool2d_pass.py @@ -3,7 +3,7 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. -from math import ceil, floor +from dataclasses import dataclass from typing import Set, Type import torch @@ -41,11 +41,20 @@ def _get_decomposition(op) -> tuple: raise RuntimeError(f"Unable to get decomposition for op {op}") +@dataclass(frozen=True) +class _PoolRegion: + start: int + end: int + kernel: int + stride: int = 1 + padding: int = 0 + + class DecomposeAdaptiveAvgPool2dPass(ArmOpTargetedPass): """Decompose static-shape topology-changing AdaptiveAvgPool2d cases. - Static input/output shapes use the existing slice + avg_pool2d + cat - lowering, with a fast path for directly representable uniform regions. + Static shapes use directly representable pooling regions. See + ``adaptive_avg_pool2d.md`` for illustrated grouping and padding examples. Dynamic cases are left untouched for dedicated dynamic rewrite/decompose passes later in the TOSA pipeline. @@ -163,10 +172,81 @@ def _is_dynamic_direct_case(self, x, output_size_h, output_size_w) -> bool: def _static_bin_bounds( input_size: int, output_size: int, out_idx: int ) -> tuple[int, int]: - start = floor(out_idx * input_size / output_size) - end = ceil((out_idx + 1) * input_size / output_size) + start = out_idx * input_size // output_size + end = ((out_idx + 1) * input_size + output_size - 1) // output_size return start, end + @classmethod + def _pool_regions( + cls, + input_size: int, + output_size: int, + max_stride: int | None, + allow_padding: bool = True, + ) -> list[_PoolRegion]: + bounds = [ + cls._static_bin_bounds(input_size, output_size, index) + for index in range(output_size) + ] + if output_size == 1: + return [_PoolRegion(0, input_size, input_size)] + + stride = input_size // output_size + if ( + stride > 0 + and (max_stride is None or stride <= max_stride) + and input_size % output_size in (0, 1) + ): + return [ + _PoolRegion(0, input_size, stride + input_size % output_size, stride) + ] + + if allow_padding and output_size >= 3: + stride = bounds[2][0] - bounds[1][0] + kernel = bounds[1][1] - bounds[1][0] + padding = stride - bounds[1][0] + if ( + stride > 0 + and (max_stride is None or stride <= max_stride) + and 0 <= padding <= kernel // 2 + and (input_size + 2 * padding - kernel) // stride + 1 == output_size + and all( + start == max(0, index * stride - padding) + and end == min(input_size, index * stride - padding + kernel) + for index, (start, end) in enumerate(bounds) + ) + ): + return [_PoolRegion(0, input_size, kernel, stride, padding)] + + regions = [] + first = 0 + while first < output_size: + start, end = bounds[first] + kernel = end - start + stop = first + 1 + stride = 1 + if stop < output_size: + next_start, next_end = bounds[stop] + candidate_stride = next_start - start + if ( + next_end - next_start == kernel + and candidate_stride > 0 + and (max_stride is None or candidate_stride <= max_stride) + ): + stride = candidate_stride + stop += 1 + while stop < output_size: + next_start, next_end = bounds[stop] + if ( + next_end - next_start != kernel + or next_start != start + (stop - first) * stride + ): + break + stop += 1 + regions.append(_PoolRegion(start, bounds[stop - 1][1], kernel, stride)) + first = stop + return regions + def _symbolic_bin_bounds(self, input_size, output_size: int, out_idx: int, meta): start_num = self._shape_mul_const(input_size, out_idx, meta) start = self._shape_floor_div_const(start_num, output_size, meta) @@ -284,70 +364,52 @@ def _supports_dynamic_tosa_adaptive() -> bool: and tosa_spec.support_extension("shape") ) - def _decompose_static( + def _emit_pool_regions( self, avg_pool2d_op, slice_op, cat_op, x, - output_size_h, - output_size_w, + height_regions, + width_regions, kwargs, meta, ): _, _, input_size_h, input_size_w = x.data.shape - stride_h = floor(input_size_h / output_size_h) - stride_w = floor(input_size_w / output_size_w) - if ( - self._is_directly_representable(input_size_h, output_size_h) - and self._is_directly_representable(input_size_w, output_size_w) - and stride_h in (1, 2, 3) - and stride_w in (1, 2, 3) - ): - kernel_h = stride_h + (input_size_h % output_size_h) - kernel_w = stride_w + (input_size_w % output_size_w) - return super().call_operator( - avg_pool2d_op, - (x, (kernel_h, kernel_w), (stride_h, stride_w), (0, 0)), - kwargs, - meta, - True, - ) - meta_with_no_qparams = meta_without_qparams(meta) - res = [] - for out_i in range(output_size_h): - row = [] - for out_j in range(output_size_w): - start_h, end_h = self._static_bin_bounds( - input_size_h, output_size_h, out_i - ) - start_w, end_w = self._static_bin_bounds( - input_size_w, output_size_w, out_j - ) - + for height in height_regions: + x_h = x + if height.start != 0 or height.end != input_size_h: x_h = super().call_operator( slice_op, - (x, 2, start_h, end_h), - kwargs, - meta_with_no_qparams, - True, - ) - x_hw = super().call_operator( - slice_op, - (x_h, 3, start_w, end_w), + (x, 2, height.start, height.end), kwargs, meta_with_no_qparams, True, ) - - kernel_h = end_h - start_h - kernel_w = end_w - start_w + row = [] + for width in width_regions: + x_hw = x_h + if width.start != 0 or width.end != input_size_w: + x_hw = super().call_operator( + slice_op, + (x_h, 3, width.start, width.end), + kwargs, + meta_with_no_qparams, + True, + ) pooled = super().call_operator( avg_pool2d_op, - (x_hw, (kernel_h, kernel_w), (1, 1), (0, 0)), + ( + x_hw, + (height.kernel, width.kernel), + (height.stride, width.stride), + (height.padding, width.padding), + False, + False, + ), kwargs, meta, True, @@ -379,6 +441,45 @@ def _decompose_static( else res[0] ) + def _decompose_static( + self, + avg_pool2d_op, + slice_op, + cat_op, + x, + output_size_h, + output_size_w, + kwargs, + meta, + ): + _, _, input_size_h, input_size_w = x.data.shape + is_u55 = get_context_spec().is_U55_subset + max_stride = 3 if is_u55 else None + height_regions = self._pool_regions(input_size_h, output_size_h, max_stride) + width_regions = self._pool_regions(input_size_w, output_size_w, max_stride) + if ( + is_u55 + and any(region.padding for region in height_regions + width_regions) + and any(region.kernel > 8 for region in height_regions + width_regions) + ): + height_regions = self._pool_regions( + input_size_h, output_size_h, max_stride, allow_padding=False + ) + width_regions = self._pool_regions( + input_size_w, output_size_w, max_stride, allow_padding=False + ) + + return self._emit_pool_regions( + avg_pool2d_op, + slice_op, + cat_op, + x, + height_regions, + width_regions, + kwargs, + meta, + ) + def _decompose_dynamic_static_output( self, x, cat_op, output_size_h: int, output_size_w: int, kwargs, meta ): diff --git a/backends/arm/test/ops/test_adaptive_avg_pool2d.py b/backends/arm/test/ops/test_adaptive_avg_pool2d.py index 2ae19ea7414..fa74f2cd529 100644 --- a/backends/arm/test/ops/test_adaptive_avg_pool2d.py +++ b/backends/arm/test/ops/test_adaptive_avg_pool2d.py @@ -43,6 +43,18 @@ def forward(self, *args, **kwargs): "output_1x1": lambda: (AdaptiveAvgPool2d((1, 1)), (torch.rand(1, 4, 8, 8),)), "output_2x2": lambda: (AdaptiveAvgPool2d((2, 2)), (torch.rand(1, 4, 10, 10),)), "output_4x4": lambda: (AdaptiveAvgPool2d((4, 4)), (torch.rand(1, 5, 15, 15),)), + "grouped_height_first": lambda: ( + AdaptiveAvgPool2d((5, 7)), + (torch.rand(1, 3, 17, 23),), + ), + "grouped_width_first": lambda: ( + AdaptiveAvgPool2d((7, 5)), + (torch.rand(1, 3, 23, 17),), + ), + "padded_4x4_from_7x7": lambda: ( + AdaptiveAvgPool2d((4, 4)), + (torch.rand(1, 3, 7, 7),), + ), "output_2x3": lambda: (AdaptiveAvgPool2d((2, 3)), (torch.rand(1, 3, 9, 13),)), "output_h_keep": lambda: ( AdaptiveAvgPool2d((2, None)), @@ -152,7 +164,7 @@ def test_adaptive_avg_pool2d_tosa_FP_fp8(test_module): tosa_extensions=[tosa_extension], compare_tosa_ref_model_outputs=False, ) - pipeline.count_tosa_ops({"AVG_POOL2D": 4}) + pipeline.count_tosa_ops({"AVG_POOL2D": 1}) pipeline.run() diff --git a/backends/arm/test/passes/test_decompose_adaptive_avg_pool2d_pass.py b/backends/arm/test/passes/test_decompose_adaptive_avg_pool2d_pass.py index abb54b4f4de..0b6b6c53998 100644 --- a/backends/arm/test/passes/test_decompose_adaptive_avg_pool2d_pass.py +++ b/backends/arm/test/passes/test_decompose_adaptive_avg_pool2d_pass.py @@ -5,6 +5,7 @@ from typing import Tuple +import pytest import torch from executorch.backends.arm._passes.decompose_adaptive_avg_pool2d_pass import ( DecomposeAdaptiveAvgPool2dPass, @@ -60,10 +61,14 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: return torch.nn.functional.adaptive_avg_pool2d(x, (2, None)) -def _run_static_decomposition(module: torch.nn.Module, inputs: input_t): +def _run_static_decomposition( + module: torch.nn.Module, + inputs: input_t, + specification="TOSA-1.0+FP", +): ep = export(module, inputs) edge_model = to_edge(ep) - with TosaLoweringContext(TosaSpecification.create_from_string("TOSA-1.0+FP")): + with TosaLoweringContext(TosaSpecification.create_from_string(specification)): edge_model = edge_model.transform([DecomposeAdaptiveAvgPool2dPass()]) return edge_model.exported_program().graph_module @@ -90,7 +95,7 @@ def test_decompose_adaptive_avg_pool2d_uniform_regions_rewrite_to_avg_pool2d(): pipeline.run() -def test_decompose_adaptive_avg_pool2d_no_target_irregular_regions(): +def test_decompose_adaptive_avg_pool2d_padded_regions(): module = AdaptiveAvgPoolIrregular() pipeline = PassPipeline[input_t]( module, @@ -99,19 +104,19 @@ def test_decompose_adaptive_avg_pool2d_no_target_irregular_regions(): "executorch_exir_dialects_edge__ops_aten__adaptive_avg_pool2d_default": 1, }, ops_after_pass={ - "executorch_exir_dialects_edge__ops_aten_avg_pool2d_default": 16, - "executorch_exir_dialects_edge__ops_aten_slice_copy_Tensor": 32, - "executorch_exir_dialects_edge__ops_aten_cat_default": 5, + "executorch_exir_dialects_edge__ops_aten_avg_pool2d_default": 1, }, ops_not_after_pass=[ "executorch_exir_dialects_edge__ops_aten__adaptive_avg_pool2d_default", + "executorch_exir_dialects_edge__ops_aten_slice_copy_Tensor", + "executorch_exir_dialects_edge__ops_aten_cat_default", ], pass_list=[DecomposeAdaptiveAvgPool2dPass], ) pipeline.run() -def test_decompose_adaptive_avg_pool2d_no_target_large_stride_still_decomposes(): +def test_decompose_adaptive_avg_pool2d_large_stride_rewrites(): module = AdaptiveAvgPoolLargeStride() pipeline = PassPipeline[input_t]( module, @@ -120,12 +125,12 @@ def test_decompose_adaptive_avg_pool2d_no_target_large_stride_still_decomposes() "executorch_exir_dialects_edge__ops_aten__adaptive_avg_pool2d_default": 1, }, ops_after_pass={ - "executorch_exir_dialects_edge__ops_aten_avg_pool2d_default": 16, - "executorch_exir_dialects_edge__ops_aten_slice_copy_Tensor": 32, - "executorch_exir_dialects_edge__ops_aten_cat_default": 5, + "executorch_exir_dialects_edge__ops_aten_avg_pool2d_default": 1, }, ops_not_after_pass=[ "executorch_exir_dialects_edge__ops_aten__adaptive_avg_pool2d_default", + "executorch_exir_dialects_edge__ops_aten_slice_copy_Tensor", + "executorch_exir_dialects_edge__ops_aten_cat_default", ], pass_list=[DecomposeAdaptiveAvgPool2dPass], ) @@ -156,9 +161,7 @@ def test_decompose_adaptive_avg_pool2d_asymmetric_regions_decompose(): "executorch_exir_dialects_edge__ops_aten__adaptive_avg_pool2d_default": 1, }, ops_after_pass={ - "executorch_exir_dialects_edge__ops_aten_avg_pool2d_default": 6, - "executorch_exir_dialects_edge__ops_aten_slice_copy_Tensor": 12, - "executorch_exir_dialects_edge__ops_aten_cat_default": 3, + "executorch_exir_dialects_edge__ops_aten_avg_pool2d_default": 1, }, ops_not_after_pass=[ "executorch_exir_dialects_edge__ops_aten__adaptive_avg_pool2d_default", @@ -178,9 +181,7 @@ def test_decompose_adaptive_avg_pool2d_keep_width_decompose(): "executorch_exir_dialects_edge__ops_aten__adaptive_avg_pool2d_default": 1, }, ops_after_pass={ - "executorch_exir_dialects_edge__ops_aten_avg_pool2d_default": 32, - "executorch_exir_dialects_edge__ops_aten_slice_copy_Tensor": 64, - "executorch_exir_dialects_edge__ops_aten_cat_default": 3, + "executorch_exir_dialects_edge__ops_aten_avg_pool2d_default": 1, }, ops_not_after_pass=[ "executorch_exir_dialects_edge__ops_aten__adaptive_avg_pool2d_default", @@ -189,3 +190,102 @@ def test_decompose_adaptive_avg_pool2d_keep_width_decompose(): pass_list=[DecomposeAdaptiveAvgPool2dPass], ) pipeline.run() + + +class AdaptiveAvgPoolShape(torch.nn.Module): + def __init__(self, output_size): + super().__init__() + self.output_size = output_size + + def forward(self, tensor): + return torch.nn.functional.adaptive_avg_pool2d(tensor, self.output_size) + + +@pytest.mark.parametrize( + "input_size,output_size,specification,pool_count", + [ + ((17, 17), (5, 5), "TOSA-1.0+FP", 9), + ((8, 8), (3, 3), "TOSA-1.0+FP", 1), + ((7, 7), (10, 10), "TOSA-1.0+FP", 49), + ((17, 23), (5, 7), "TOSA-1.0+FP", 9), + ((23, 17), (7, 5), "TOSA-1.0+FP", 9), + ((1, 1), (3, 3), "TOSA-1.0+FP", 9), + ((7, 7), (4, 4), "TOSA-1.0+INT+u55", 1), + ((32, 32), (4, 4), "TOSA-1.0+INT+u55", 16), + ((9, 13), (2, 3), "TOSA-1.0+INT+u55", 6), + ((10, 16), (2, 16), "TOSA-1.0+INT+u55", 2), + ((7, 32), (4, 2), "TOSA-1.0+INT+u55", 6), + ], +) +def test_decompose_adaptive_avg_pool2d_regions( + input_size, output_size, specification, pool_count +): + module = AdaptiveAvgPoolShape(output_size) + inputs = (torch.randn(1, 3, *input_size),) + transformed = _run_static_decomposition(module, inputs, specification) + pools = [ + node + for node in transformed.graph.nodes + if "aten.avg_pool2d.default" in str(node.target) + ] + assert len(pools) == pool_count + if "+u55" in specification: + for node in pools: + assert all(1 <= stride <= 3 for stride in node.args[2]) + if any(node.args[3]): + assert all(kernel <= 8 for kernel in node.args[1]) + torch.testing.assert_close(transformed(*inputs)[0], module(*inputs)) + + +@pytest.mark.parametrize("max_stride", [None, 3]) +def test_adaptive_avg_pool2d_axis_regions_cover_exact_windows(max_stride): + for input_size in range(1, 65): + for output_size in range(1, 65): + regions = DecomposeAdaptiveAvgPool2dPass._pool_regions( + input_size, output_size, max_stride + ) + windows = [] + for region in regions: + assert region.stride > 0 + if max_stride is not None: + assert region.stride <= max_stride + count = ( + region.end - region.start + 2 * region.padding - region.kernel + ) // region.stride + 1 + for index in range(count): + start = region.start + index * region.stride - region.padding + windows.append( + ( + max(region.start, start), + min(region.end, start + region.kernel), + ) + ) + assert windows == [ + DecomposeAdaptiveAvgPool2dPass._static_bin_bounds( + input_size, output_size, index + ) + for index in range(output_size) + ] + + +def test_adaptive_avg_pool2d_bin_bounds_use_integer_arithmetic(): + input_size = 2**60 + 3 + assert DecomposeAdaptiveAvgPool2dPass._static_bin_bounds(input_size, 3, 2) == ( + 2 * input_size // 3, + input_size, + ) + + +@pytest.mark.parametrize("dtype", [torch.float16, torch.float32]) +def test_adaptive_avg_pool2d_grouped_without_separable(dtype): + module = AdaptiveAvgPoolShape((5, 5)) + inputs = (torch.randn(1, 3, 17, 17).to(dtype),) + transformed = _run_static_decomposition(module, inputs) + assert ( + sum( + "aten.avg_pool2d.default" in str(node.target) + for node in transformed.graph.nodes + ) + == 9 + ) + torch.testing.assert_close(transformed(*inputs)[0], module(*inputs)) From 6d903f4742170f8ff624c9d8b9e3475854a37419 Mon Sep 17 00:00:00 2001 From: Jacob Stevens Date: Tue, 3 Nov 2026 09:58:11 -0500 Subject: [PATCH 008/128] Reinstall pip from PyPI in the native wheel pre-build script (#24461) ### Summary conda-forge's pip 26.2.2 build 1 (conda-forge/pip-feedstock#151, published 2026-11-02) removes pip's bundled `cacert.pem` and makes `certifi.where()` return `sys.prefix/ssl/cacert.pem`. `backends/native/python/setup.py` builds with `python -m build`, which installs build dependencies by running the outer pip with `--python` pointed at its isolated venv. That venv's `sys.prefix` has no `ssl/cacert.pem`, so pip fails with `FileNotFoundError` in `load_verify_locations` ([example](https://github.com/pytorch/executorch/actions/runs/41501634985/job/125470594908)). Every "Build Aarch64 Linux Native Wheels" PR run has failed this way since about 17:17 UTC on 2026-11-02, because the aarch64 image resolves pip from conda-forge. The x86 image gets pip from Anaconda's defaults channel and is unaffected. Reinstalling pip from PyPI, which bundles its own certificates, before installing `build` avoids this. The line can be removed once conda-forge's pip works inside venvs again. Authored with Claude Code. ### Test plan Reproduced locally (osx-arm64) in a conda env created with `-c conda-forge pip=26.2.2=*_1`: `python -m build --wheel` on a project with the native wheel's build requirements fails with the same traceback. After `python -m pip install --force-reinstall pip`, the same command installs `certifi==2026.10.23`, `pybind11==3.1.0`, `pyyaml==6.0.3`, `scikit-build-core==1.1.1` and `zstd==1.5.7.2` into the isolated env and succeeds. This PR touches `.ci/`, so it runs "Build Aarch64 Linux Native Wheels" itself. --- .ci/scripts/wheel/pre_build_native.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.ci/scripts/wheel/pre_build_native.sh b/.ci/scripts/wheel/pre_build_native.sh index 77cf82c2a42..ecd92d60056 100755 --- a/.ci/scripts/wheel/pre_build_native.sh +++ b/.ci/scripts/wheel/pre_build_native.sh @@ -7,6 +7,10 @@ set -euxo pipefail +# conda-forge's pip reads CA certificates from sys.prefix +# (conda-forge/pip-feedstock#151), which `python -m build`'s isolated venv +# lacks. PyPI's pip bundles its own. +python -m pip install --force-reinstall pip python -m pip install build 'setuptools>=77' 'cmake>=3.26,<4' ninja auditwheel patchelf python -m unittest discover -s "${NATIVE_WHEEL_ROOT}/.ci/scripts/tests" \ -p test_native_wheel_version.py From 2799a79227a1b18f8a0ce43c4f5818b4c439421e Mon Sep 17 00:00:00 2001 From: Digant Desai Date: Tue, 3 Nov 2026 09:05:46 -0600 Subject: [PATCH 009/128] Keep max_pool2d and avg_pool2d test inputs within the exported shape bounds (#24483) Summary: `test_vulkan_backend_max_pool2d` and `test_vulkan_backend_avg_pool2d` export with a `(5, 13, 55, 68)` sample input and mark only the batch dimension as dynamic, so dimension 1 has an upper bound of 13. Their first test input, `(3, 14, 15, 9)`, exceeds that bound. Since #24203, `set_input` enforces per-dimension upper bounds for `DYNAMIC_BOUND` inputs, so both tests now fail with `Input 0 dimension 1 is 14 but its upper bound is 13`. Use 13 for dimension 1 of that input so every test input stays within the exported bounds. The other two inputs already do. Authored with AI assistance. Reviewed By: shoumikhin Differential Revision: D129264803 Pull Request resolved: https://github.com/pytorch/executorch/pull/24483 --- backends/vulkan/test/test_vulkan_delegate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backends/vulkan/test/test_vulkan_delegate.py b/backends/vulkan/test/test_vulkan_delegate.py index 68681688457..27c698d2e55 100644 --- a/backends/vulkan/test/test_vulkan_delegate.py +++ b/backends/vulkan/test/test_vulkan_delegate.py @@ -672,7 +672,7 @@ def forward(self, x): batch = Dim("batch", max=8) dynamic_shapes = {"x": {0: batch}} test_inputs = [ - (torch.randn(3, 14, 15, 9),), + (torch.randn(3, 13, 15, 9),), (torch.randn(1, 1, 4, 6),), (torch.randn(5, 10, 50, 40),), ] @@ -706,7 +706,7 @@ def forward(self, x): batch = Dim("batch", max=8) dynamic_shapes = {"x": {0: batch}} test_inputs = [ - (torch.randn(3, 14, 15, 9),), + (torch.randn(3, 13, 15, 9),), (torch.randn(1, 1, 4, 6),), (torch.randn(5, 10, 50, 40),), ] From e655cb89425cd0e86336635da0ce6229b150c9f3 Mon Sep 17 00:00:00 2001 From: Elena Zhelezina Date: Tue, 3 Nov 2026 15:37:02 +0000 Subject: [PATCH 010/128] Arm backend: Add FP8 to automatic list of generated ops, VGF. (#24496) Extended automatic list of supported ops to include FP8 formats. cc @digantdesai @freddan80 @per @zingo @oscarandersson8218 @mansnils @Sebastian-Larsson @robell @rascani Signed-off-by: Elena Zhelezina --- .../arm/scripts/docgen/generate_op_support.py | 843 ++++++++++- .../arm/test/misc/test_docgen_op_support.py | 1310 +++++++++++++++++ .../source/backends/arm-vgf/VGF_op_support.md | 85 +- 3 files changed, 2170 insertions(+), 68 deletions(-) diff --git a/backends/arm/scripts/docgen/generate_op_support.py b/backends/arm/scripts/docgen/generate_op_support.py index 6ef1816ab53..2f1a385a647 100644 --- a/backends/arm/scripts/docgen/generate_op_support.py +++ b/backends/arm/scripts/docgen/generate_op_support.py @@ -43,7 +43,8 @@ # below are shared. Backend differences belong in BackendConfig. # --------------------------------------------------------------------------- BACKEND_NAME = "VGF" -BACKEND_PIPELINE_CLASS_NAMES = frozenset({"VgfPipeline"}) +# VgfPipelineFP8 is testing pipeline for MXFP dtypes +BACKEND_PIPELINE_CLASS_NAMES = frozenset({"VgfPipeline", "VgfPipelineFP8"}) BACKEND_PIPELINE_LABEL = "VgfPipeline" BACKEND_TOSA_SPEC = "TOSA-1.0+FP+INT+int4+int16" BACKEND_PROFILE_TOSA_SPECS = { @@ -82,7 +83,7 @@ class BackendConfig: "vgf": BackendConfig( key="vgf", name="VGF", - pipeline_class_names=frozenset({"VgfPipeline"}), + pipeline_class_names=frozenset({"VgfPipeline", "VgfPipelineFP8"}), pipeline_label="VgfPipeline", tosa_spec="TOSA-1.0+FP+INT+int4+int16", default_output=Path("docs/source/backends/arm-vgf/VGF_op_support.md"), @@ -434,6 +435,18 @@ def _activate_backend(backend: str | BackendConfig) -> BackendConfig: ): { "INT": {"torch.ops.aten.linalg_vector_norm.default"}, }, + ( + "backends/arm/test/ops/test_to_copy.py", + "test_to_vgf_no_quant_fp8_with_extension", + ): { + "FP": {"torch.ops.aten._to_copy.default"}, + }, + ( + "backends/arm/test/ops/test_to_copy.py", + "test_to_vgf_no_quant_bf16_fp8_with_extensions", + ): { + "FP": {"torch.ops.aten._to_copy.default"}, + }, } # Existing U55 runtime tests below intentionally suppress direct ATen/Edge @@ -1078,6 +1091,10 @@ def _positional_or_keyword(call: ast.Call, index: int, name: str) -> ast.AST | N def _pipeline_profile(call: ast.Call) -> str: + # VgfPipelineFP8 injects quantize=False in its own constructor, + # so this setting is not visible at the call site. + if ACTIVE_BACKEND_KEY == "vgf" and _call_name(call.func) == "VgfPipelineFP8": + return "FP" if PIPELINE_QUANTIZE_KEYWORD is None: return PIPELINE_DEFAULT_PROFILE quantize = _literal_bool( @@ -1981,10 +1998,31 @@ def _has_xfail_marker(node: ast.AST) -> bool: return "xfail" in _expr_text(node).lower() +def _fp8_dtypes_from_text(text: str) -> set[str]: + """Recognize concrete FP8 tensor dtypes, not enabled TOSA extensions. + + E4M3FN and E5M2 have different encodings. In particular, PyTorch's FNUZ + variants must not be silently conflated with the TOSA/VGF FP8 formats. + + """ + value = text.lower() + dtypes: set[str] = set() + # "fp8e4m3" and "fp8e5m2" are also TOSA extension names. + # Only the PyTorch float8_* dtype tokens prove the tensor dtype. + if re.search(r"(? str | None: text = _expr_text(node).lower() attr = _attribute_name(node).lower() if isinstance(node, ast.Attribute) else "" combined = f"{text} {attr}" + fp8_dtypes = _fp8_dtypes_from_text(combined) + if len(fp8_dtypes) == 1: + return next(iter(fp8_dtypes)) if "bfloat16" in combined or "bf16" in combined: return "BF16" if "float16" in combined or "fp16" in combined: @@ -1996,6 +2034,38 @@ def _dtype_from_expr(node: ast.AST) -> str | None: return None +def _dtype_hints_from_call_expr(node: ast.Call, context: ModuleContext) -> set[str]: + name = _call_name(node.func) + if name == "param" and _has_xfail_marker(node): + return set() + if name == "param": + # pytest.param has one positional value per parametrized name. + # A dtype can appear after a shape, module, or other case argument. + dtypes: set[str] = set() + for arg in node.args: + dtypes.update(_dtype_hints_from_data_expr(arg, context)) + return dtypes + # Factory arguments hold the concrete dtype even when the helper return + # body is generic (e.g. _make_test_case(... .to(torch.float8_e4m3fn))). + call_dtypes = _dtype_hints_from_text(_expr_text(node)) + if call_dtypes: + return call_dtypes + if name in context.functions: + return _dtype_hints_from_helper(context.functions[name]) + return set() + + +def _dtype_hints_from_class_attribute( + node: ast.Attribute, context: ModuleContext +) -> set[str]: + # Support class-held parameter tables, e.g. Cat.test_parameters_fp8. + if isinstance(node.value, ast.Name): + class_value = _class_attr_value(node.value.id, node.attr, context) + if class_value is not None and class_value is not node: + return _dtype_hints_from_data_expr(class_value, context) + return _dtype_hints_from_text(_attribute_name(node)) + + def _dtype_hints_from_data_expr(node: ast.AST, context: ModuleContext) -> set[str]: if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr): return _dtype_hints_from_data_expr( @@ -2015,10 +2085,7 @@ def _dtype_hints_from_data_expr(node: ast.AST, context: ModuleContext) -> set[st return dictionary_dtypes if isinstance(node, ast.Call): - name = _call_name(node.func) - if name in context.functions: - return _dtype_hints_from_helper(context.functions[name]) - return _dtype_hints_from_text(_expr_text(node)) + return _dtype_hints_from_call_expr(node, context) if isinstance(node, ast.Name): assigned = context.assignments.get(node.id) @@ -2027,7 +2094,7 @@ def _dtype_hints_from_data_expr(node: ast.AST, context: ModuleContext) -> set[st return _dtype_hints_from_text(node.id) if isinstance(node, ast.Attribute): - return _dtype_hints_from_text(_attribute_name(node)) + return _dtype_hints_from_class_attribute(node, context) return _dtype_hints_from_text(_expr_text(node)) @@ -2047,8 +2114,8 @@ def collect(node: ast.AST) -> None: # missing runtime support. if _has_xfail_marker(node): return - if node.args: - collect(node.args[0]) + for arg in node.args: + collect(arg) return dtype = _dtype_from_expr(node) @@ -2065,6 +2132,12 @@ def collect(node: ast.AST) -> None: def _dtype_hints_from_text(text: str) -> set[str]: value = text.lower() + fp8_dtypes = _fp8_dtypes_from_text(value) + if fp8_dtypes: + # FP8 tensors are commonly created via float32/float16 .to(FP8). + # The source constructor dtype is not the operator's FP8 input dtype. + # Dtypes from separate parameter cases are still unioned by the caller. + return fp8_dtypes dtypes: set[str] = set() if "test_data_bf16" in value or "bfloat16" in value or "bf16" in value: dtypes.add("BF16") @@ -2079,35 +2152,734 @@ def _dtype_hints_from_text(text: str) -> set[str]: return dtypes -def _infer_fp_dtypes( - function: ast.FunctionDef, call: ast.Call, context: ModuleContext +def _fp_parameter_alias(node: ast.AST, context: ModuleContext) -> ast.AST | None: + """Resolve a test table stored in a module variable or class field.""" + if isinstance(node, ast.Name): + return context.assignments.get(node.id) + if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name): + return _class_attr_value(node.value.id, node.attr, context) + return None + + +def _fp_parameter_dict_cases( + node: ast.Dict, context: ModuleContext, seen: set[str] +) -> list[ast.AST]: + """Expand the entries of one dictionary parameter table.""" + cases: list[ast.AST] = [] + for key, value in zip(node.keys, node.values): + if value is None: + continue + # Dict unpacking (`{**baseline, **fp8}`) is another way to + # assemble mixed tables. Preserve the individual case boundary. + if key is None: + cases.extend(_fp_parameter_cases(value, context, seen)) + else: + cases.append(value) + return cases + + +def _fp_parameter_alias_cases( + node: ast.AST, context: ModuleContext, seen: set[str] +) -> list[ast.AST] | None: + """Expand a module/class alias while preventing cycles.""" + alias = _fp_parameter_alias(node, context) + if alias is not None: + key = _expr_text(node) + if key not in seen: + return _fp_parameter_cases(alias, context, seen | {key}) + return None + + +def _fp_parameter_function_cases( + node: ast.Call, context: ModuleContext, seen: set[str] +) -> list[ast.AST] | None: + """Expand a table returned by a local helper function.""" + helper = context.functions.get(_call_name(node.func)) + if helper is not None: + key = _call_name(node.func) + if key not in seen: + results: list[ast.AST] = [] + for child in ast.walk(helper): + if isinstance(child, ast.Return) and child.value is not None: + results.extend( + _fp_parameter_cases(child.value, context, seen | {key}) + ) + if results: + return results + return None + + +def _fp_parameter_cases( + node: ast.AST, + context: ModuleContext, + seen: set[str] | None = None, +) -> list[ast.AST]: + """Expand the *outer* parametrization table into independent cases. + + Crucially, do not combine dtype information for different cases before + deciding whether one of them uses the implicit FP32 default. + + """ + if seen is None: + seen = set() + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr): + return _fp_parameter_cases(node.left, context, seen) + _fp_parameter_cases( + node.right, context, seen + ) + if isinstance(node, ast.Dict): + return _fp_parameter_dict_cases(node, context, seen) + if isinstance(node, (ast.List, ast.Tuple, ast.Set)): + return list(node.elts) + + alias_cases = _fp_parameter_alias_cases(node, context, seen) + if alias_cases is not None: + return alias_cases + + if isinstance(node, ast.Call): + function_cases = _fp_parameter_function_cases(node, context, seen) + if function_cases is not None: + return function_cases + return [node] + + +def _fp_case_uses_implicit_fp32(case: ast.AST) -> bool: + """Identify a case that constructs default-FP32 floating-point tensors. + + This is only consulted when the *case* lacks an explicit dtype. For a + `torch.randn(..., dtype=torch.float32).to(torch.float8_e4m3fn)` case, + concrete FP8 inference takes priority and this fallback is not invoked. + + """ + default_factories = { + "torch.rand", + "torch.randn", + "torch.ones", + "torch.zeros", + "torch.empty", + "torch.eye", + "torch.linspace", + "torch.logspace", + "torch.normal", + "torch.Tensor", + } + for node in ast.walk(case): + if not isinstance(node, ast.Call): + continue + name = _expr_text(node.func) + if name in default_factories and not any( + keyword.arg == "dtype" for keyword in node.keywords + ): + return True + if name == "torch.full" and len(node.args) >= 2: + if any(keyword.arg == "dtype" for keyword in node.keywords): + continue + if isinstance(node.args[1], ast.Constant) and isinstance( + node.args[1].value, float + ): + return True + if name == "torch.tensor" and node.args: + if any(keyword.arg == "dtype" for keyword in node.keywords): + continue + if any( + isinstance(child, ast.Constant) and isinstance(child.value, float) + for child in ast.walk(node.args[0]) + ): + return True + return False + + +def _fp_case_local_helper_returns( + case: ast.AST, + context: ModuleContext, + seen: frozenset[str] = frozenset(), +) -> list[ast.AST]: + """Follow local helper return expressions without expanding call cycles. + + `seen` is path-local: separate cases can share helpers, and recursive + helpers (direct or mutual) cannot make the documentation scan loop. + + """ + values: list[ast.AST] = [] + for node in ast.walk(case): + if not isinstance(node, ast.Call): + continue + name = _call_name(node.func) + if name in seen: + continue + helper = context.functions.get(name) + if helper is None: + continue + next_seen = seen | {name} + for child in ast.walk(helper): + if isinstance(child, ast.Return) and child.value is not None: + values.append(child.value) + values.extend( + _fp_case_local_helper_returns(child.value, context, next_seen) + ) + return values + + +def _fp_helper_return_dtype_hints(node: ast.AST, context: ModuleContext) -> set[str]: + """Prefer the final conversion/factory dtype over an FP32 source buffer.""" + if isinstance(node, (ast.Tuple, ast.List, ast.Set)): + dtypes: set[str] = set() + for element in node.elts: + dtypes.update(_fp_helper_return_dtype_hints(element, context)) + return dtypes + if isinstance(node, ast.Call): + dtype_expr = next( + (keyword.value for keyword in node.keywords if keyword.arg == "dtype"), + None, + ) + if ( + dtype_expr is None + and isinstance(node.func, ast.Attribute) + and node.func.attr == "to" + and node.args + ): + dtype_expr = node.args[0] + if dtype_expr is not None: + dtype = _dtype_from_expr(dtype_expr) + if dtype is not None: + return {dtype} + return _dtype_hints_from_data_expr(node, context) + + +def _fp_case_dtypes_with_local_helpers( + case: ast.AST, context: ModuleContext ) -> set[str]: + """Resolve helper-based test cases before the existing FP32 fallback. + + Inspect each helper's returned expression, not unrelated statements in its + body. An explicit final dtype takes precedence over construction buffers, + while a returned default floating-point factory proves FP32. + + """ + dtypes = _dtype_hints_from_data_expr(case, context) + if dtypes: + return dtypes + for returned in _fp_case_local_helper_returns(case, context): + hints = _fp_helper_return_dtype_hints(returned, context) + if hints: + dtypes.update(hints) + elif _fp_case_uses_implicit_fp32(returned): + dtypes.add("FP32") + return dtypes + + +def _fp_named_parameter_names(node: ast.AST) -> list[str]: + """Handle both pytest's comma-separated and tuple/list argname forms.""" + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return [name for name in node.value.replace(",", " ").split() if name] + if isinstance(node, (ast.Tuple, ast.List)): + if all( + isinstance(part, ast.Constant) and isinstance(part.value, str) + for part in node.elts + ): + return [ + part.value.strip() + for part in node.elts + if isinstance(part, ast.Constant) + and isinstance(part.value, str) + and part.value.strip() + ] + return [] + + +def _fp_is_input_parameter(name: str) -> bool: + """Do not confuse masks, expected outputs, or compiler settings with + inputs. + """ + name = name.strip().lower() + auxiliary = ( + "mask", + "expected", + "reference", + "output", + "result", + "extension", + "config", + "cfg", + "spec", + "weight_dtype", + "bias_dtype", + "scale", + "zero_point", + "quant", + "device", + "shape", + "stride", + "layout", + "tol", + "rtol", + "atol", + "kernel", + "padding", + "axis", + "dim", + "index", + "indices", + "value_dtype", + "target_dtype", + ) + if any(token in name for token in auxiliary): + return False + return ( + name + in { + "dtype", + "data", + "input", + "inputs", + "tensor", + "x", + "lhs", + "rhs", + "a", + "b", + } + or name.startswith( + ( + "test_data", + "test_input", + "test_case", + "test_module", + "input_dtype", + "input_data", + "input_tensor", + "test_dtype", + "activation_dtype", + "tensor_dtype", + "data_dtype", + ) + ) + or name.endswith("_input") + ) + + +def _fp_is_dtype_parameter(name: str) -> bool: + """A named input dtype is stronger evidence than its construction buffer.""" + name = name.strip().lower() + return _fp_is_input_parameter(name) and (name == "dtype" or name.endswith("_dtype")) + + +def _fp_primary_data_expression(node: ast.AST, name: str) -> ast.AST: + """Extract MaskedFill's data input, not its separate BOOL mask. + + The Arm masked_fill test encodes a case as + lambda: (MaskedFill(), (data_tensor, boolean_mask, scalar)). + This rule is intentionally limited to that recognizable fixture shape; + arbitrary multi-input operators are not assumed to use their first tensor. + + """ + if not name.strip().lower().startswith(("test_module", "test_case")): + return node + body = node.body if isinstance(node, ast.Lambda) else node + if not isinstance(body, (ast.Tuple, ast.List)) or len(body.elts) < 2: + return node + module, inputs = body.elts[:2] + if not isinstance(module, ast.Call) or _call_name(module.func) != "MaskedFill": + return node + if isinstance(inputs, (ast.Tuple, ast.List)) and inputs.elts: + return inputs.elts[0] + return node + + +def _fp_named_case_values(case: ast.AST, names: Sequence[str]) -> Sequence[ast.AST]: + """Return positional values without considering pytest.param metadata.""" + if isinstance(case, ast.Call) and _call_name(case.func) == "param": + return [] if _has_xfail_marker(case) else case.args + if len(names) > 1 and isinstance(case, (ast.Tuple, ast.List)): + return case.elts + if len(names) == 1: + return [case] + # An opaque row for multiple names has no reliable positional mapping. + return [] + + +def _fp_named_case_inputs( + case: ast.AST, names: Sequence[str] +) -> list[tuple[str, ast.AST]]: + """Zip active positional case values with their decorator argnames. + + pytest.param metadata (id/marks) is never a dtype source. If a multi-name + row cannot be aligned, do not guess that one of its values is an input. + + """ + if not names or not any(_fp_is_input_parameter(name) for name in names): + return [] + values = _fp_named_case_values(case, names) + if len(values) != len(names): + return [] + return [ + (name, _fp_primary_data_expression(value, name)) + for name, value in zip(names, values) + if _fp_is_input_parameter(name) + ] + + +def _fp_module_forward_positional_names( + module: ast.AST, context: ModuleContext +) -> list[str] | None: + """Find the positional forward signature of a module defined in this + test. + """ + if not isinstance(module, ast.Call): + return None + class_name = _call_name(module.func) + for statement in context.tree.body: + if not isinstance(statement, ast.ClassDef) or statement.name != class_name: + continue + for member in statement.body: + if isinstance(member, ast.FunctionDef) and member.name == "forward": + names = [ + arg.arg for arg in (*member.args.posonlyargs, *member.args.args) + ] + return names[1:] if names and names[0] == "self" else names + return None + + +def _fp_forward_parameter_is_data(name: str) -> bool: + """Filter masks, conditions, indices, and scalar controls from input + dtype. + """ + value = name.lower() + if value.startswith(("mask", "condition", "indices", "index")): + return False + if value.endswith(("_mask", "_index", "_indices")): + return False + return value not in { + "cond", + "dim", + "dims", + "axis", + "axes", + "shape", + "size", + "sizes", + "keepdim", + "keep_dims", + "value", + "scalar", + "fill_value", + "threshold", + "stride", + "padding", + "device", + "dtype", + "layout", + "expected", + "result", + "output", + "out", + } + + +def _fp_module_fixture_inputs( + module: ast.AST, inputs: ast.AST, context: ModuleContext +) -> list[ast.AST] | None: + """Match (module, positional_forward_inputs) to its input names.""" + if not isinstance(inputs, (ast.Tuple, ast.List)): + return None + names = _fp_module_forward_positional_names(module, context) + if names is None or len(inputs.elts) > len(names): + return None + # Scope the forward-signature rule to modules with an auxiliary mask. + # Other modules retain their previous operator-dtype inference behavior. + has_mask = any( + name.lower() in {"mask", "condition", "cond"} + or name.lower().startswith("mask_") + or name.lower().endswith("_mask") + for name in names + ) + if not has_mask or not any(_fp_forward_parameter_is_data(n) for n in names): + return None + return [ + expression + for name, expression in zip(names, inputs.elts) + if _fp_forward_parameter_is_data(name) + ] + + +def _fp_case_fixture_data_expressions( + case: ast.AST, context: ModuleContext +) -> list[ast.AST] | None: + """Find data operands even behind lambda wrappers or local return helpers. + + Unlike a MaskedFill()-specific lambda pattern, this uses the actual module's + forward signature to exclude boolean masks. Other modules with + mask/condition arguments benefit from the same rule. + + """ + values = [case, *_fp_case_local_helper_returns(case, context)] + relevant: list[ast.AST] = [] + found = False + for value in values: + for node in ast.walk(value): + if isinstance(node, (ast.Tuple, ast.List)) and len(node.elts) >= 2: + data = _fp_module_fixture_inputs(node.elts[0], node.elts[1], context) + elif isinstance(node, ast.Call) and len(node.args) >= 2: + data = _fp_module_fixture_inputs(node.args[0], node.args[1], context) + else: + continue + if data is not None: + found = True + relevant.extend(data) + return relevant if found else None + + +def _fp_fixture_primary_input_dtypes( + value: ast.AST, context: ModuleContext +) -> set[str] | None: + """Infer each selected forward data operand, never the BOOL mask.""" + operands = _fp_case_fixture_data_expressions(value, context) + if operands is None: + return None dtypes: set[str] = set() + for operand in operands: + hints = _fp_case_dtypes_with_local_helpers(operand, context) + if hints: + dtypes.update(hints) + elif _fp_case_uses_implicit_fp32(operand): + dtypes.add("FP32") + return dtypes - test_data_expr = _positional_or_keyword(call, 1, "test_data") - if test_data_expr is not None: - dtypes.update(_dtype_hints_from_data_expr(test_data_expr, context)) +def _fp_is_default_float_constructor(node: ast.Call) -> bool: + """Recognize an implicit-FP32 factory without traversing sibling tensors.""" + if any(keyword.arg == "dtype" for keyword in node.keywords): + return False + name = _expr_text(node.func) + if name in { + "torch.rand", + "torch.randn", + "torch.ones", + "torch.zeros", + "torch.empty", + "torch.eye", + "torch.linspace", + "torch.logspace", + "torch.normal", + "torch.Tensor", + }: + return True + if name == "torch.full" and len(node.args) >= 2: + return isinstance(node.args[1], ast.Constant) and isinstance( + node.args[1].value, float + ) + if name == "torch.tensor" and node.args: + return any( + isinstance(child, ast.Constant) and isinstance(child.value, float) + for child in ast.walk(node.args[0]) + ) + return False + + +def _fp_factory_is_converted_or_compared( + factory: ast.Call, parents: dict[ast.AST, ast.AST] +) -> bool: + """A temporary factory used for .to(FP8/FP16) or a BOOL mask is not FP32.""" + current: ast.AST = factory + while current in parents: + current = parents[current] + if isinstance(current, ast.Compare): + return True + if isinstance(current, ast.Call) and isinstance(current.func, ast.Attribute): + if current.func.attr == "to" and ( + current.args + or any(keyword.arg == "dtype" for keyword in current.keywords) + ): + return True + return False + + +def _fp_has_unconverted_implicit_fp32(value: ast.AST, context: ModuleContext) -> bool: + """Inspect factories independently instead of allowing BOOL to mask FP32.""" + expressions = [value, *_fp_case_local_helper_returns(value, context)] + for expression in expressions: + parents = { + child: parent + for parent in ast.walk(expression) + for child in ast.iter_child_nodes(parent) + } + for node in ast.walk(expression): + if ( + isinstance(node, ast.Call) + and _fp_is_default_float_constructor(node) + and not _fp_factory_is_converted_or_compared(node, parents) + ): + return True + return False + + +def _fp_named_expression_dtypes(value: ast.AST, context: ModuleContext) -> set[str]: + """An implicit FP32 factory is evidence only for its selected input.""" + # Read the forward data operands before considering mask dtypes. + fixture_dtypes = _fp_fixture_primary_input_dtypes(value, context) + if fixture_dtypes is not None: + return fixture_dtypes + # Separate implicit FP32 tensors from any explicit auxiliary dtype. + if _fp_has_unconverted_implicit_fp32(value, context): + return _fp_case_dtypes_with_local_helpers(value, context) | {"FP32"} + + hints = _fp_case_dtypes_with_local_helpers(value, context) + if hints: + return hints + return {"FP32"} if _fp_case_uses_implicit_fp32(value) else set() + + +def _fp_named_case_dtype_hints( + case: ast.AST, context: ModuleContext, names: Sequence[str] +) -> set[str]: + """Classify only relevant input values from one active parameter case.""" + if ( + isinstance(case, ast.Call) + and _call_name(case.func) == "param" + and _has_xfail_marker(case) + ): + return set() + selected = _fp_named_case_inputs(case, names) + # An explicit input dtype determines the test's final dtype; do not also + # claim FP32 from temporary source tensors in other positions. + dtype_hints: set[str] = set() + for name, value in selected: + if _fp_is_dtype_parameter(name): + dtype_hints.update(_fp_case_dtypes_with_local_helpers(value, context)) + if dtype_hints: + return dtype_hints + dtypes: set[str] = set() + for _name, value in selected: + dtypes.update(_fp_named_expression_dtypes(value, context)) + return dtypes + + +def _fp_named_case_unresolved( + case: ast.AST, context: ModuleContext, names: Sequence[str] +) -> bool: + """Conservatively retain FP32 if an active input case stays opaque.""" + if ( + isinstance(case, ast.Call) + and _call_name(case.func) == "param" + and _has_xfail_marker(case) + ): + return False + if not any(_fp_is_input_parameter(name) for name in names): + return False # Auxiliary-only decorators are irrelevant to input dtype. + selected = _fp_named_case_inputs(case, names) + if not selected: + return True # Ambiguous positional mapping: cannot prove the dtype. + return not _fp_named_case_dtype_hints(case, context, names) + + +def _fp_dtype_hints_from_cases( + table: ast.AST, context: ModuleContext, names: Sequence[str] = () +) -> set[str]: + """Union input dtype evidence from independently classified active cases.""" + dtypes: set[str] = set() + for case in _fp_parameter_cases(table, context): + dtypes.update(_fp_named_case_dtype_hints(case, context, names)) + return dtypes + + +def _parametrized_fp_dtype_hints( + function: ast.FunctionDef, context: ModuleContext +) -> set[str]: + dtypes: set[str] = set() for decorator in function.decorator_list: decorator_text = _expr_text(decorator).lower() if "parametrize" not in decorator_text: continue - if isinstance(decorator, ast.Call) and len(decorator.args) >= 2: - param_name = decorator.args[0] - if isinstance(param_name, ast.Constant) and isinstance( - param_name.value, str - ): - if "test_data" in param_name.value or "dtype" in param_name.value: - dtypes.update( - _dtype_hints_from_data_expr(decorator.args[1], context) - ) - else: + if not isinstance(decorator, ast.Call) or len(decorator.args) < 2: dtypes.update(_dtype_hints_from_text(decorator_text)) + continue + param_names = _fp_named_parameter_names(decorator.args[0]) + if any(_fp_is_input_parameter(name) for name in param_names): + dtypes.update( + _fp_dtype_hints_from_cases(decorator.args[1], context, param_names) + ) + return dtypes - # The common test_data dictionaries use torch.randn/torch.ones without an - # explicit dtype, which means FP32. Keep that as the default FP path. - if not dtypes: + +def _fp_case_has_unresolved_dtype( + case: ast.AST, context: ModuleContext, names: Sequence[str] = () +) -> bool: + """Whether an active case has an unresolved primary input dtype.""" + return _fp_named_case_unresolved(case, context, names) + + +def _fp_table_has_unresolved_dtype_cases( + table: ast.AST, context: ModuleContext, names: Sequence[str] = () +) -> bool: + """Check resolution for each relevant input position, not auxiliary + dtypes. + """ + return any( + _fp_case_has_unresolved_dtype(case, context, names) + for case in _fp_parameter_cases(table, context) + ) + + +def _fp_has_unresolved_parametrized_cases( + function: ast.FunctionDef, context: ModuleContext +) -> bool: + """Inspect active input/dtype parametrizations used for FP evidence. + + Use the same parameter-name categories as _parametrized_fp_dtype_hints(). An + unrecognized case makes the table non-exhaustively classified; the + historical FP32 placeholder must not be discarded on that basis. + + """ + for decorator in function.decorator_list: + if not isinstance(decorator, ast.Call) or len(decorator.args) < 2: + continue + if "parametrize" not in _expr_text(decorator.func).lower(): + continue + names = _fp_named_parameter_names(decorator.args[0]) + if not any(_fp_is_input_parameter(name) for name in names): + continue + if _fp_table_has_unresolved_dtype_cases(decorator.args[1], context, names): + return True + return False + + +def _infer_fp_dtypes( + function: ast.FunctionDef, call: ast.Call, context: ModuleContext +) -> set[str]: + test_data_expr = _positional_or_keyword(call, 1, "test_data") + input_dtypes = ( + _dtype_hints_from_data_expr(test_data_expr, context) + if test_data_expr is not None + else set() + ) + parametrized_dtypes = _parametrized_fp_dtype_hints(function, context) + + # `test_data` and `test_data()` are parameter placeholders, not + # independent FP32 cases. The table scan above records FP32 from actual + # default-FP32 cases and FP8 only from concrete FP8 cases. + if ( + parametrized_dtypes + and input_dtypes == {"FP32"} + and test_data_expr is not None + and _expr_text(test_data_expr).lower() in {"test_data", "test_data()"} + ): + if not _fp_has_unresolved_parametrized_cases(function, context): + input_dtypes = set() + + dtypes = input_dtypes | parametrized_dtypes + # AACR: unresolved cases may include default FP32 through local aliases. + if ( + parametrized_dtypes + and _call_name(call.func) != "VgfPipelineFP8" + and _fp_has_unresolved_parametrized_cases(function, context) + ): dtypes.add("FP32") + if not dtypes: + # A missing FP8 case resolution must not silently turn into FP32. + if ( + _call_name(call.func) != "VgfPipelineFP8" + and "fp8" not in function.name.lower() + ): + dtypes.add("FP32") return dtypes @@ -2702,6 +3474,16 @@ def generate_markdown(repo_root: Path, *, debug: bool = False) -> str: "", ] + if ACTIVE_BACKEND_KEY == "vgf": + lines.extend( + [ + "`FP8E4M3` and `FP8E5M2` require their respective TOSA " + "extensions (`fp8e4m3`, `fp8e5m2`); they are not enabled " + "by the default VGF compile spec. ", + "", + ] + ) + if debug: sorted_rows = sorted( exact_rows.values(), @@ -2798,6 +3580,13 @@ def generate_html(repo_root: Path, *, debug: bool = False) -> str: "

8x8 means 8-bit activations and 8-bit weights. 16x8 means 16-bit activations and 8-bit weights.

", ] + if ACTIVE_BACKEND_KEY == "vgf": + lines.append( + "

FP8E4M3 and FP8E5M2 require explicit TOSA " + "extensions (fp8e4m3, fp8e5m2); they are not enabled by " + "the default VGF compile spec.

" + ) + if debug: sorted_rows = sorted( exact_rows.values(), diff --git a/backends/arm/test/misc/test_docgen_op_support.py b/backends/arm/test/misc/test_docgen_op_support.py index 6ec83ab5e87..6fa26789f9c 100644 --- a/backends/arm/test/misc/test_docgen_op_support.py +++ b/backends/arm/test/misc/test_docgen_op_support.py @@ -840,3 +840,1313 @@ def test_linalg_vector_norm_explicit_backend_coverage( ][profile] == {op} finally: docgen._activate_backend(original) + + +# FP8 VGF documentation evidence tests. + + +def test_docgen_recognizes_vgf_fp8_pipeline_and_unquantized_profile() -> None: + original = docgen.ACTIVE_BACKEND_KEY + try: + config = docgen._activate_backend("vgf") + assert "VgfPipelineFP8" in config.pipeline_class_names + for expression in ("VgfPipelineFP8()", "VgfPipelineFP8[input_t]()"): + statement = ast.parse(expression).body[0] + assert isinstance(statement, ast.Expr) + assert isinstance(statement.value, ast.Call) + assert docgen._pipeline_profile(statement.value) == "FP" + finally: + docgen._activate_backend(original) + + +def test_fp8_dtype_inference_ignores_fp32_tensor_creation() -> None: + assert docgen._dtype_hints_from_text( + "torch.rand(2, dtype=torch.float32).to(torch.float8_e4m3fn)" + ) == {"FP8E4M3"} + assert docgen._dtype_hints_from_text( + "torch.rand(2, dtype=torch.float32).to(torch.float8_e5m2)" + ) == {"FP8E5M2"} + assert docgen._fp8_dtypes_from_text("torch.float8_e4m3fnuz") == set() + assert docgen._fp8_dtypes_from_text("torch.float8_e5m2fnuz") == set() + + +def test_scan_vgf_fp8_cases_with_factory_calls(tmp_path: Path) -> None: + _write_test_module( + tmp_path, + "backends/arm/test/ops/test_fp8_matmul.py", + """ + test_suite_fp8 = { + "e4m3": lambda: make_case( + lambda: torch.rand(2, dtype=torch.float32).to(torch.float8_e4m3fn) + ), + "e5m2": lambda: make_case( + lambda: torch.rand(2, dtype=torch.float32).to(torch.float8_e5m2) + ), + } + + @common.parametrize("test_case", test_suite_fp8) + def test_matmul_vgf_no_quant_fp8(test_case): + VgfPipelineFP8( + object(), test_case(), + aten_op="torch.ops.aten.matmul.default", exir_op=[] + ) + """, + ) + rows, unresolved, diagnostics = docgen._scan_backend_pipeline_tests(tmp_path) + assert unresolved == [] + assert diagnostics == [] + row = rows["torch.ops.aten.matmul.default"] + assert row.support_profiles == {"FP"} + assert row.dtypes == {"FP8E4M3", "FP8E5M2"} + assert not row.quantization_modes + + +def test_scan_vgf_fp8_cases_from_class_attribute(tmp_path: Path) -> None: + _write_test_module( + tmp_path, + "backends/arm/test/ops/test_fp8_cat.py", + """ + class Cat: + test_parameters_fp8 = { + "e4m3": lambda: ( + torch.rand(2, dtype=torch.float32).to(torch.float8_e4m3fn), + "fp8e4m3", + ), + "e5m2": lambda: ( + torch.rand(2, dtype=torch.float32).to(torch.float8_e5m2), + "fp8e5m2", + ), + } + + @common.parametrize("test_data", Cat.test_parameters_fp8) + def test_cat_vgf_no_quant_fp8(test_data): + VgfPipeline( + object(), test_data(), + aten_op="torch.ops.aten.cat.default", exir_op=[], + quantize=False, + tosa_extensions=["fp8e4m3", "fp8e5m2"], + ) + """, + ) + rows, unresolved, _diagnostics = docgen._scan_backend_pipeline_tests(tmp_path) + assert unresolved == [] + row = rows["torch.ops.aten.cat.default"] + assert row.support_profiles == {"FP"} + assert row.dtypes == {"FP8E4M3", "FP8E5M2"} + + +def test_scan_vgf_fp8_cast_with_suppressed_operator_assertions(tmp_path: Path) -> None: + _write_test_module( + tmp_path, + "backends/arm/test/ops/test_to_copy.py", + """ + fp8_inputs = { + "e4m3": lambda: torch.rand(2, dtype=torch.float32).to(torch.float8_e4m3fn), + "e5m2": lambda: torch.rand(2, dtype=torch.float32).to(torch.float8_e5m2), + } + + @common.parametrize("test_data", fp8_inputs) + def test_to_vgf_no_quant_fp8_with_extension(test_data): + VgfPipeline( + object(), test_data(), aten_op=[], exir_op=[], quantize=False + ) + """, + ) + rows, unresolved, _diagnostics = docgen._scan_backend_pipeline_tests(tmp_path) + assert unresolved == [] + row = rows["torch.ops.aten._to_copy.default"] + assert row.support_profiles == {"FP"} + assert row.dtypes == {"FP8E4M3", "FP8E5M2"} + + +def test_scan_vgf_fp8_no_false_fp32_fallback(tmp_path: Path) -> None: + _write_test_module( + tmp_path, + "backends/arm/test/ops/test_fp8_unresolved_dtype.py", + """ + def test_unknown_vgf_fp8(): + VgfPipelineFP8( + object(), opaque_inputs(), + aten_op="torch.ops.aten.relu.default", exir_op=[], + ) + """, + ) + rows, unresolved, _diagnostics = docgen._scan_backend_pipeline_tests(tmp_path) + assert unresolved == [] + assert rows["torch.ops.aten.relu.default"].dtypes == set() + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + # Merely enabling TOSA extensions must NOT establish tensor dtype. + ('tosa_extensions=["fp8e4m3", "fp8e5m2"]', set()), + ('{"tosa_extensions": ["fp8e4m3"]}', set()), + ('{"tosa_extensions": ["fp8e5m2"]}', set()), + ("fp8e4m3", set()), + ("fp8e5m2", set()), + ("case_fp8e4m3", set()), + # Real PyTorch tensor dtypes still establish FP8 evidence. + ("torch.float8_e4m3fn", {"FP8E4M3"}), + ("torch.float8_e5m2", {"FP8E5M2"}), + ( + "torch.randn(4, dtype=torch.float32).to(torch.float8_e4m3fn)", + {"FP8E4M3"}, + ), + ( + "torch.randn(4, dtype=torch.float32).to(torch.float8_e5m2)", + {"FP8E5M2"}, + ), + # Unsupported FNUZ variants and larger names are not the same dtype. + ("torch.float8_e4m3fnuz", set()), + ("torch.float8_e5m2fnuz", set()), + ("custom_float8_e4m3fn_suffix", set()), + ], +) +def test_fp8_dtype_recognizer_excludes_tosa_extension_names( + text: str, expected: set[str] +) -> None: + assert docgen._fp8_dtypes_from_text(text) == expected + assert (docgen._dtype_hints_from_text(text) & {"FP8E4M3", "FP8E5M2"}) == expected + + +def test_vgf_fp8_extensions_without_fp8_tensors_are_not_dtype_evidence( + tmp_path: Path, +) -> None: + original = docgen.ACTIVE_BACKEND_KEY + try: + docgen._activate_backend("vgf") + _write_test_module( + tmp_path, + "backends/arm/test/ops/test_vgf_fp8_extension_only.py", + """ + test_cases = { + "case_e4m3": { + "tensor": torch.ones(4, dtype=torch.float32), + "tosa_extensions": ["fp8e4m3"], + }, + "case_e5m2": { + "tensor": torch.ones(4, dtype=torch.float32), + "tosa_extensions": ["fp8e5m2"], + }, + } + + @common.parametrize("test_data", test_cases) + def test_relu_vgf_no_quant_with_extensions(test_data): + VgfPipeline( + object(), + test_data, + aten_op="torch.ops.aten.relu.default", + exir_op=[], + quantize=False, + tosa_extensions=["fp8e4m3", "fp8e5m2"], + ) + """, + ) + rows, unresolved, diagnostics = docgen._scan_backend_pipeline_tests(tmp_path) + assert not unresolved + assert not diagnostics + row = rows["torch.ops.aten.relu.default"] + assert row.support_profiles == {"FP"} + assert row.dtypes == {"FP32"} + finally: + docgen._activate_backend(original) + + +# Regression tests for multi-argument pytest.param dtype inference. + + +def test_vgf_fp8_pytest_param_all_positional_dtypes(tmp_path: Path) -> None: + """The dtype in pytest.param(shape, dtype) must not be discarded.""" + original = docgen.ACTIVE_BACKEND_KEY + try: + docgen._activate_backend("vgf") + _write_test_module( + tmp_path, + "backends/arm/test/ops/test_vgf_fp8_multi_param.py", + """ + @pytest.mark.parametrize( + "shape,dtype", + [ + pytest.param((2, 2), torch.float8_e4m3fn, id="e4m3"), + pytest.param((2, 2), torch.float8_e5m2, id="e5m2"), + pytest.param((2, 2), torch.float16, id="fp16"), + pytest.param((2, 2), torch.bfloat16, id="bf16"), + pytest.param( + (2, 2), torch.float32, + marks=pytest.mark.xfail(reason="unsupported"), + id="xfail-fp32", + ), + ], + ) + def test_matmul_vgf_no_quant_fp8(shape, dtype): + VgfPipelineFP8( + object(), opaque_inputs, + aten_op="torch.ops.aten.matmul.default", exir_op=[], + ) + """, + ) + rows, unresolved, diagnostics = docgen._scan_backend_pipeline_tests(tmp_path) + assert not unresolved + assert not diagnostics + row = rows["torch.ops.aten.matmul.default"] + assert row.support_profiles == {"FP"} + assert row.dtypes == {"FP8E4M3", "FP8E5M2", "FP16", "BF16"} + finally: + docgen._activate_backend(original) + + +def test_vgf_fp8_pytest_param_from_helper_collects_all_args( + tmp_path: Path, +) -> None: + """Case factories returning pytest.param also retain later dtype args.""" + original = docgen.ACTIVE_BACKEND_KEY + try: + docgen._activate_backend("vgf") + _write_test_module( + tmp_path, + "backends/arm/test/ops/test_vgf_fp8_helper_param.py", + """ + def make_cases(): + return [ + pytest.param((2, 2), torch.float8_e4m3fn), + pytest.param((4, 4), torch.float16), + pytest.param( + (8, 8), torch.bfloat16, + marks=pytest.mark.xfail(reason="unsupported"), + ), + ] + + @pytest.mark.parametrize("shape,dtype", make_cases()) + def test_relu_vgf_no_quant_fp8(shape, dtype): + VgfPipelineFP8( + object(), opaque_inputs, + aten_op="torch.ops.aten.relu.default", exir_op=[], + ) + """, + ) + rows, unresolved, diagnostics = docgen._scan_backend_pipeline_tests(tmp_path) + assert not unresolved + assert not diagnostics + row = rows["torch.ops.aten.relu.default"] + assert row.support_profiles == {"FP"} + assert row.dtypes == {"FP8E4M3", "FP16"} + finally: + docgen._activate_backend(original) + + +def test_vgf_fp8_pytest_param_extensions_are_not_dtype_evidence( + tmp_path: Path, +) -> None: + """A later tosa_extensions argument must not claim FP8 tensors.""" + original = docgen.ACTIVE_BACKEND_KEY + try: + docgen._activate_backend("vgf") + _write_test_module( + tmp_path, + "backends/arm/test/ops/test_vgf_fp8_extensions_param.py", + """ + @pytest.mark.parametrize( + "shape,dtype,extensions", + [ + pytest.param( + (2, 2), torch.float32, ["fp8e4m3", "fp8e5m2"], + ), + ], + ) + def test_add_vgf_no_quant_fp8_extensions(shape, dtype, extensions): + VgfPipeline( + object(), opaque_inputs, + aten_op="torch.ops.aten.add.Tensor", exir_op=[], + quantize=False, + ) + """, + ) + rows, unresolved, diagnostics = docgen._scan_backend_pipeline_tests(tmp_path) + assert not unresolved + assert not diagnostics + row = rows["torch.ops.aten.add.Tensor"] + assert row.support_profiles == {"FP"} + assert row.dtypes == {"FP32"} + finally: + docgen._activate_backend(original) + + +# Regression coverage for AACR: mixed FP8 and default-FP32 parametrization. + + +def test_vgf_fp8_mixed_case_table_preserves_implicit_fp32(tmp_path: Path) -> None: + """Implicit FP32 remains when a separate case converts its input to FP8.""" + original = docgen.ACTIVE_BACKEND_KEY + try: + docgen._activate_backend("vgf") + _write_test_module( + tmp_path, + "backends/arm/test/ops/test_docgen_mixed_fp8_fp32.py", + """ + regular_cases = { + "default_fp32": lambda: (torch.rand(2, 2),), + "another_default": lambda: (torch.randn(4, 4),), + } + fp8_cases = { + "e4m3": lambda: ( + torch.rand(2, 2, dtype=torch.float32).to(torch.float8_e4m3fn), + ), + "e5m2": lambda: ( + torch.rand(4, 4, dtype=torch.float32).to(torch.float8_e5m2), + ), + } + + @common.parametrize("test_data", regular_cases | fp8_cases) + def test_add_vgf_no_quant(test_data): + VgfPipeline( + object(), test_data, + aten_op="torch.ops.aten.add.Tensor", exir_op=[], quantize=False, + ) + """, + ) + rows, unresolved, diagnostics = docgen._scan_backend_pipeline_tests(tmp_path) + assert not unresolved + assert not diagnostics + row = rows["torch.ops.aten.add.Tensor"] + assert row.support_profiles == {"FP"} + assert row.dtypes == {"FP32", "FP8E4M3", "FP8E5M2"} + finally: + docgen._activate_backend(original) + + +def test_vgf_fp8_only_case_table_does_not_invent_fp32(tmp_path: Path) -> None: + """The FP32 source buffer of .to(float8_*) is not an FP32 test case.""" + original = docgen.ACTIVE_BACKEND_KEY + try: + docgen._activate_backend("vgf") + _write_test_module( + tmp_path, + "backends/arm/test/ops/test_docgen_only_fp8.py", + """ + fp8_cases = { + "e4m3": lambda: ( + torch.rand(2, 2, dtype=torch.float32).to(torch.float8_e4m3fn), + ), + "e5m2": lambda: ( + torch.randn(2, 2, dtype=torch.float32).to(torch.float8_e5m2), + ), + } + + @common.parametrize("test_data", fp8_cases) + def test_add_vgf_no_quant_fp8(test_data): + VgfPipelineFP8( + object(), test_data, + aten_op="torch.ops.aten.add.Tensor", exir_op=[], + ) + """, + ) + rows, unresolved, diagnostics = docgen._scan_backend_pipeline_tests(tmp_path) + assert not unresolved + assert not diagnostics + assert rows["torch.ops.aten.add.Tensor"].dtypes == {"FP8E4M3", "FP8E5M2"} + finally: + docgen._activate_backend(original) + + +def test_vgf_fp8_multi_argument_and_xfail_cases_preserve_fp32( + tmp_path: Path, +) -> None: + """A later dtype arg and active FP32 case count; an xfail does not.""" + original = docgen.ACTIVE_BACKEND_KEY + try: + docgen._activate_backend("vgf") + _write_test_module( + tmp_path, + "backends/arm/test/ops/test_docgen_fp8_multi_args_mixed.py", + """ + @pytest.mark.parametrize( + "shape,dtype", + [ + pytest.param((2, 2), torch.float8_e4m3fn), + pytest.param((4, 4), torch.float32), + pytest.param( + (8, 8), torch.bfloat16, + marks=pytest.mark.xfail(reason="unsupported"), + ), + ], + ) + def test_relu_vgf_no_quant_fp8(shape, dtype): + VgfPipelineFP8( + object(), opaque_inputs, + aten_op="torch.ops.aten.relu.default", exir_op=[], + ) + """, + ) + rows, unresolved, diagnostics = docgen._scan_backend_pipeline_tests(tmp_path) + assert not unresolved + assert not diagnostics + assert rows["torch.ops.aten.relu.default"].dtypes == {"FP32", "FP8E4M3"} + finally: + docgen._activate_backend(original) + + +def test_vgf_fp8_class_held_mixed_table_retains_fp32(tmp_path: Path) -> None: + """Class attributes and dict unions must preserve default-FP32 entries.""" + original = docgen.ACTIVE_BACKEND_KEY + try: + docgen._activate_backend("vgf") + _write_test_module( + tmp_path, + "backends/arm/test/ops/test_docgen_fp8_class_mixed.py", + """ + class Inputs: + defaults = { + "default": lambda: (torch.zeros(2, 2),), + } + e4m3 = { + "fp8": lambda: ( + torch.randn(2, 2, dtype=torch.float32) + .to(torch.float8_e4m3fn), + ), + } + + @common.parametrize("test_data", Inputs.defaults | Inputs.e4m3) + def test_max_vgf_no_quant(test_data): + VgfPipeline( + object(), test_data, + aten_op="torch.ops.aten.max.dim", exir_op=[], quantize=False, + ) + """, + ) + rows, unresolved, diagnostics = docgen._scan_backend_pipeline_tests(tmp_path) + assert not unresolved + assert not diagnostics + assert rows["torch.ops.aten.max.dim"].dtypes == {"FP32", "FP8E4M3"} + finally: + docgen._activate_backend(original) + + +# Regression tests: AACR review of helper-hidden implicit FP32 evidence. + + +def test_vgf_fp8_local_helpers_restore_implicit_fp32(tmp_path: Path) -> None: + """Mixed cases must retain FP32 when a nested local helper creates it.""" + original = docgen.ACTIVE_BACKEND_KEY + try: + docgen._activate_backend("vgf") + _write_test_module( + tmp_path, + "backends/arm/test/ops/test_docgen_helper_fp32_mixed.py", + """ + def make_default(shape): + return torch.randn(shape) + + def make_nested(shape): + return make_default(shape) + + cases = { + "implicit_fp32": lambda: (make_nested((2, 2)),), + "fp16": lambda: (torch.randn(2, dtype=torch.float16),), + "bf16": lambda: (torch.randn(2, dtype=torch.bfloat16),), + "fp8": lambda: ( + torch.randn(2, dtype=torch.float32).to(torch.float8_e4m3fn), + ), + } + + @common.parametrize("test_data", cases) + def test_relu_vgf_no_quant(test_data): + VgfPipeline( + object(), test_data, + aten_op="torch.ops.aten.relu.default", exir_op=[], + quantize=False, + ) + """, + ) + rows, unresolved, diagnostics = docgen._scan_backend_pipeline_tests(tmp_path) + assert not unresolved + assert not diagnostics + assert rows["torch.ops.aten.relu.default"].dtypes == { + "FP32", + "FP16", + "BF16", + "FP8E4M3", + } + finally: + docgen._activate_backend(original) + + +def test_vgf_fp8_local_helper_final_dtype_precedes_source_fp32( + tmp_path: Path, +) -> None: + """Do not claim FP32 from an FP8/FP16 helper's temporary buffer.""" + original = docgen.ACTIVE_BACKEND_KEY + try: + docgen._activate_backend("vgf") + _write_test_module( + tmp_path, + "backends/arm/test/ops/test_docgen_helper_fp8_casts.py", + """ + def make_e4m3(): + return torch.randn(2, dtype=torch.float32).to(torch.float8_e4m3fn) + + def make_fp16(): + return torch.randn(2, dtype=torch.float32).to(torch.float16) + + cases = { + "fp8": lambda: (make_e4m3(),), + "fp16": lambda: (make_fp16(),), + } + + @common.parametrize("test_data", cases) + def test_cat_vgf_no_quant(test_data): + VgfPipeline( + object(), test_data, + aten_op="torch.ops.aten.cat.default", exir_op=[], + quantize=False, + tosa_extensions=["fp8e4m3"], + ) + """, + ) + rows, unresolved, diagnostics = docgen._scan_backend_pipeline_tests(tmp_path) + assert not unresolved + assert not diagnostics + assert rows["torch.ops.aten.cat.default"].dtypes == {"FP8E4M3", "FP16"} + finally: + docgen._activate_backend(original) + + +def test_vgf_fp8_recursive_local_helpers_are_cycle_safe(tmp_path: Path) -> None: + """Mutually recursive helpers must not loop or fabricate FP32 coverage.""" + original = docgen.ACTIVE_BACKEND_KEY + try: + docgen._activate_backend("vgf") + _write_test_module( + tmp_path, + "backends/arm/test/ops/test_docgen_recursive_helpers.py", + """ + def make_a(): + return make_b() + + def make_b(): + return make_a() + + cases = { + "cycle": lambda: make_a(), + "fp16": lambda: (torch.randn(2, dtype=torch.float16),), + } + + @common.parametrize("test_data", cases) + def test_relu_vgf_no_quant(test_data): + VgfPipeline( + object(), test_data, + aten_op="torch.ops.aten.relu.default", exir_op=[], + quantize=False, + ) + """, + ) + rows, unresolved, diagnostics = docgen._scan_backend_pipeline_tests(tmp_path) + assert not unresolved + assert not diagnostics + assert rows["torch.ops.aten.relu.default"].dtypes == {"FP16", "FP32"} + finally: + docgen._activate_backend(original) + + +# AACR Patchset 5: unresolved parameter entries must not erase FP32. + + +def test_vgf_fp8_unknown_imported_case_retains_fp32(tmp_path: Path) -> None: + """Opaque imported helper plus FP16 must preserve legacy FP32 coverage.""" + original = docgen.ACTIVE_BACKEND_KEY + try: + docgen._activate_backend("vgf") + _write_test_module( + tmp_path, + "backends/arm/test/ops/test_opaque_imported_fp32.py", + """ + cases = { + "imported_default": lambda: imported_make_case(), + "fp16": lambda: (torch.randn(2, dtype=torch.float16),), + } + + @common.parametrize("test_data", cases) + def test_relu_vgf_no_quant(test_data): + VgfPipeline( + object(), test_data, + aten_op="torch.ops.aten.relu.default", exir_op=[], + quantize=False, + ) + """, + ) + rows, unresolved, diagnostics = docgen._scan_backend_pipeline_tests(tmp_path) + assert not unresolved + assert not diagnostics + assert rows["torch.ops.aten.relu.default"].dtypes == {"FP32", "FP16"} + finally: + docgen._activate_backend(original) + + +def test_vgf_fp8_opaque_case_with_local_data_alias_retains_fp32( + tmp_path: Path, +) -> None: + """Unknown parameter cases preserve FP32 even when passed as `data`.""" + original = docgen.ACTIVE_BACKEND_KEY + try: + docgen._activate_backend("vgf") + _write_test_module( + tmp_path, + "backends/arm/test/ops/test_local_data_alias_mixed.py", + """ + cases = { + "opaque": lambda: imported_default_case(), + "fp16": lambda: (torch.randn(2, dtype=torch.float16),), + } + + @common.parametrize("test_data", cases) + def test_relu_vgf_no_quant(test_data): + data = test_data() + VgfPipeline( + object(), data, + aten_op="torch.ops.aten.relu.default", exir_op=[], + quantize=False, + ) + """, + ) + rows, unresolved, diagnostics = docgen._scan_backend_pipeline_tests(tmp_path) + assert not unresolved + assert not diagnostics + assert rows["torch.ops.aten.relu.default"].dtypes == {"FP32", "FP16"} + finally: + docgen._activate_backend(original) + + +def test_vgf_fp8_unknown_fp32_case_with_fp8_preserves_both(tmp_path: Path) -> None: + """An opaque case plus explicit FP8 must not erase possible FP32.""" + original = docgen.ACTIVE_BACKEND_KEY + try: + docgen._activate_backend("vgf") + _write_test_module( + tmp_path, + "backends/arm/test/ops/test_opaque_fp8_mixed.py", + """ + cases = { + "opaque": lambda: fixture_make_case(), + "e4m3": lambda: ( + torch.randn(2, dtype=torch.float32).to(torch.float8_e4m3fn), + ), + } + + @common.parametrize("test_data", cases) + def test_relu_vgf_no_quant(test_data): + VgfPipeline( + object(), test_data, + aten_op="torch.ops.aten.relu.default", exir_op=[], + quantize=False, tosa_extensions=["fp8e4m3"], + ) + """, + ) + rows, unresolved, diagnostics = docgen._scan_backend_pipeline_tests(tmp_path) + assert not unresolved + assert not diagnostics + assert rows["torch.ops.aten.relu.default"].dtypes == {"FP32", "FP8E4M3"} + finally: + docgen._activate_backend(original) + + +def test_vgf_fp8_resolved_only_fp8_cases_do_not_gain_fp32(tmp_path: Path) -> None: + """Suppress the FP32 placeholder when *all* entries resolve as FP8.""" + original = docgen.ACTIVE_BACKEND_KEY + try: + docgen._activate_backend("vgf") + _write_test_module( + tmp_path, + "backends/arm/test/ops/test_only_resolved_fp8_cases.py", + """ + cases = { + "e4m3": lambda: ( + torch.randn(2, dtype=torch.float32).to(torch.float8_e4m3fn), + ), + "e5m2": lambda: ( + torch.randn(2, dtype=torch.float32).to(torch.float8_e5m2), + ), + } + + @common.parametrize("test_data", cases) + def test_relu_vgf_no_quant(test_data): + VgfPipeline( + object(), test_data, + aten_op="torch.ops.aten.relu.default", exir_op=[], + quantize=False, tosa_extensions=["fp8e4m3", "fp8e5m2"], + ) + """, + ) + rows, unresolved, diagnostics = docgen._scan_backend_pipeline_tests(tmp_path) + assert not unresolved + assert not diagnostics + assert rows["torch.ops.aten.relu.default"].dtypes == {"FP8E4M3", "FP8E5M2"} + finally: + docgen._activate_backend(original) + + +def test_vgf_fp8_xfailed_opaque_case_does_not_force_fp32(tmp_path: Path) -> None: + """An excluded xfail case does not make the active table unresolved.""" + original = docgen.ACTIVE_BACKEND_KEY + try: + docgen._activate_backend("vgf") + _write_test_module( + tmp_path, + "backends/arm/test/ops/test_fp8_with_xfailed_opaque.py", + """ + @pytest.mark.parametrize( + "test_data", + [ + pytest.param( + lambda: opaque_imported_factory(), + marks=pytest.mark.xfail(reason="unsupported"), + ), + pytest.param( + lambda: torch.randn(2).to(torch.float8_e4m3fn), + ), + ], + ) + def test_relu_vgf_no_quant(test_data): + VgfPipeline( + object(), test_data, + aten_op="torch.ops.aten.relu.default", exir_op=[], + quantize=False, tosa_extensions=["fp8e4m3"], + ) + """, + ) + rows, unresolved, diagnostics = docgen._scan_backend_pipeline_tests(tmp_path) + assert not unresolved + assert not diagnostics + assert rows["torch.ops.aten.relu.default"].dtypes == {"FP8E4M3"} + finally: + docgen._activate_backend(original) + + +def test_vgf_fp8_unknown_class_held_case_preserves_fp32(tmp_path: Path) -> None: + """Opaque module instances in class tables also retain FP32.""" + original = docgen.ACTIVE_BACKEND_KEY + try: + docgen._activate_backend("vgf") + _write_test_module( + tmp_path, + "backends/arm/test/ops/test_class_held_opaque_cases.py", + """ + class Cases: + inputs = { + "opaque": external_instance, + "bf16": lambda: (torch.randn(2, dtype=torch.bfloat16),), + } + + @common.parametrize("test_data", Cases.inputs) + def test_relu_vgf_no_quant(test_data): + VgfPipeline( + object(), test_data, + aten_op="torch.ops.aten.relu.default", exir_op=[], + quantize=False, + ) + """, + ) + rows, unresolved, diagnostics = docgen._scan_backend_pipeline_tests(tmp_path) + assert not unresolved + assert not diagnostics + assert {"FP32", "BF16"} <= rows["torch.ops.aten.relu.default"].dtypes + finally: + docgen._activate_backend(original) + + +# AACR: only operator input positions in multi-argument pytest.param count. + + +def test_vgf_docgen_named_params_ignore_auxiliary_dtypes(tmp_path: Path) -> None: + """Do not advertise masks, expected outputs, or TOSA config as input + dtypes. + """ + original = docgen.ACTIVE_BACKEND_KEY + try: + docgen._activate_backend("vgf") + _write_test_module( + tmp_path, + "backends/arm/test/ops/test_named_param_aux.py", + """ + @pytest.mark.parametrize( + "input_dtype,mask_dtype,expected_dtype,extensions", + [ + pytest.param( + torch.float32, torch.bool, torch.float8_e4m3fn, + ["fp8e4m3"], id="fp32-data", + ), + pytest.param( + torch.float16, torch.bool, torch.float8_e5m2, + ["fp8e5m2"], id="fp16-data", + ), + ], + ) + def test_add_vgf_no_quant( + input_dtype, mask_dtype, expected_dtype, extensions + ): + VgfPipeline( + object(), opaque_inputs, + aten_op="torch.ops.aten.add.Tensor", exir_op=[], quantize=False, + ) + """, + ) + rows, unresolved, diagnostics = docgen._scan_backend_pipeline_tests(tmp_path) + assert not unresolved + assert not diagnostics + assert rows["torch.ops.aten.add.Tensor"].dtypes == {"FP32", "FP16"} + finally: + docgen._activate_backend(original) + + +def test_vgf_docgen_multi_arg_data_mask_expected_preserves_fp32( + tmp_path: Path, +) -> None: + """A default FP32 input plus FP8 input must not inherit BOOL mask dtype.""" + original = docgen.ACTIVE_BACKEND_KEY + try: + docgen._activate_backend("vgf") + _write_test_module( + tmp_path, + "backends/arm/test/ops/test_named_data_mask.py", + """ + @pytest.mark.parametrize( + "data,mask,expected", + [ + pytest.param( + torch.rand(2), torch.ones(2, dtype=torch.bool), + torch.float16, + ), + pytest.param( + torch.rand(2, dtype=torch.float32).to(torch.float8_e4m3fn), + torch.ones(2, dtype=torch.bool), torch.bfloat16, + ), + ], + ) + def test_add_vgf_no_quant(data, mask, expected): + VgfPipeline( + object(), opaque_inputs, + aten_op="torch.ops.aten.add.Tensor", exir_op=[], + quantize=False, tosa_extensions=["fp8e4m3"], + ) + """, + ) + rows, unresolved, diagnostics = docgen._scan_backend_pipeline_tests(tmp_path) + assert not unresolved + assert not diagnostics + assert rows["torch.ops.aten.add.Tensor"].dtypes == {"FP32", "FP8E4M3"} + finally: + docgen._activate_backend(original) + + +def test_vgf_docgen_named_params_keep_input_fp8_not_mask_or_expected( + tmp_path: Path, +) -> None: + """Shape/dtype mapping counts FP8, not a BOOL mask or FP16 output.""" + original = docgen.ACTIVE_BACKEND_KEY + try: + docgen._activate_backend("vgf") + _write_test_module( + tmp_path, + "backends/arm/test/ops/test_named_fp8_param.py", + """ + @pytest.mark.parametrize( + ("shape", "dtype", "mask", "expected"), + [ + pytest.param( + (2, 2), torch.float8_e4m3fn, + torch.ones(2, dtype=torch.bool), torch.float16, + id="fp8-with-bool-mask", + ), + pytest.param( + (2, 2), torch.float8_e5m2, + torch.ones(2, dtype=torch.bool), torch.bfloat16, + id="another-fp8", + ), + ], + ) + def test_add_vgf_no_quant_fp8(shape, dtype, mask, expected): + VgfPipelineFP8( + object(), opaque_inputs, + aten_op="torch.ops.aten.add.Tensor", exir_op=[], + ) + """, + ) + rows, unresolved, diagnostics = docgen._scan_backend_pipeline_tests(tmp_path) + assert not unresolved + assert not diagnostics + assert rows["torch.ops.aten.add.Tensor"].dtypes == {"FP8E4M3", "FP8E5M2"} + finally: + docgen._activate_backend(original) + + +def test_vgf_docgen_named_params_exclude_auxiliary_only_dtypes( + tmp_path: Path, +) -> None: + """A table of mask/expected values cannot establish FP8 or BOOL inputs.""" + original = docgen.ACTIVE_BACKEND_KEY + try: + docgen._activate_backend("vgf") + _write_test_module( + tmp_path, + "backends/arm/test/ops/test_named_aux_only.py", + """ + @pytest.mark.parametrize( + "mask_dtype,expected_dtype", + [pytest.param(torch.bool, torch.float8_e4m3fn)], + ) + def test_add_vgf_no_quant_fp8(mask_dtype, expected_dtype): + VgfPipelineFP8( + object(), opaque_inputs, + aten_op="torch.ops.aten.add.Tensor", exir_op=[], + ) + """, + ) + rows, unresolved, diagnostics = docgen._scan_backend_pipeline_tests(tmp_path) + assert not unresolved + assert not diagnostics + assert rows["torch.ops.aten.add.Tensor"].dtypes == set() + finally: + docgen._activate_backend(original) + + +def test_vgf_docgen_masked_fill_uses_primary_data_dtype(tmp_path: Path) -> None: + """BOOL mask must not replace the implicit FP32 masked_fill data tensor.""" + original = docgen.ACTIVE_BACKEND_KEY + try: + docgen._activate_backend("vgf") + _write_test_module( + tmp_path, + "backends/arm/test/ops/test_masked_fill.py", + """ + class MaskedFill: + pass + + test_modules = { + "bool_mask": lambda: ( + MaskedFill(), + ( + torch.rand(2, 2), + torch.ones(2, 2, dtype=torch.bool), + 3.14, + ), + ), + "comparison_mask": lambda: ( + MaskedFill(), + (torch.randn(2, 2), torch.rand(2, 2) < 0.5, 0.0), + ), + } + + @common.parametrize("test_module", test_modules) + def test_masked_fill_scalar_vgf_no_quant(test_module): + module, inputs = test_module() + VgfPipeline( + module, inputs, + aten_op="torch.ops.aten.masked_fill.Scalar", + exir_op=[], quantize=False, + ) + """, + ) + rows, unresolved, diagnostics = docgen._scan_backend_pipeline_tests(tmp_path) + assert not unresolved + assert not diagnostics + assert rows["torch.ops.aten.masked_fill.Scalar"].dtypes == {"FP32"} + finally: + docgen._activate_backend(original) + + +def test_vgf_docgen_named_params_xfail_not_counted(tmp_path: Path) -> None: + original = docgen.ACTIVE_BACKEND_KEY + try: + docgen._activate_backend("vgf") + _write_test_module( + tmp_path, + "backends/arm/test/ops/test_named_xfail.py", + """ + @pytest.mark.parametrize( + "shape,dtype,mask", + [ + pytest.param( + (2, 2), torch.float8_e4m3fn, + torch.bool, + marks=pytest.mark.xfail(reason="unsupported"), + ), + pytest.param((2, 2), torch.float32, torch.bool), + ], + ) + def test_relu_vgf_no_quant(shape, dtype, mask): + VgfPipeline( + object(), opaque_inputs, + aten_op="torch.ops.aten.relu.default", exir_op=[], + quantize=False, + ) + """, + ) + rows, unresolved, diagnostics = docgen._scan_backend_pipeline_tests(tmp_path) + assert not unresolved + assert not diagnostics + assert rows["torch.ops.aten.relu.default"].dtypes == {"FP32"} + finally: + docgen._activate_backend(original) + + +# Patchset 7 AACR: explicit BOOL masks must not replace data-tensor FP32. + + +def test_vgf_docgen_masked_fill_actual_fixture_primary_dtype(tmp_path: Path) -> None: + """Match the real test_masked_fill module/input tuple and aten_op=[] + flow. + """ + original = docgen.ACTIVE_BACKEND_KEY + try: + docgen._activate_backend("vgf") + _write_test_module( + tmp_path, + "backends/arm/test/ops/test_masked_fill.py", + """ + aten_op = "torch.ops.aten.masked_fill.Scalar" + + class MaskedFill(torch.nn.Module): + def forward(self, x, mask, value): + return torch.masked_fill(x, mask, value) + + def make_fp32_masked_fill_case(): + return ( + MaskedFill(), + ( + torch.rand(1, 3, 4, 5), + torch.ones(1, 3, 4, 5, dtype=torch.bool), + 7.0, + ), + ) + + test_modules = { + "boolean_mask": lambda: ( + MaskedFill(), + ( + torch.rand(1, 3, 4, 5), + torch.rand(1, 3, 4, 5) < 0.5, + -1.0, + ), + ), + "full_mask": lambda: make_fp32_masked_fill_case(), + } + + @common.parametrize("test_module", test_modules) + def test_masked_fill_scalar_vgf_no_quant(test_module): + module, inputs = test_module() + pipeline = VgfPipeline( + module, inputs, aten_op=[], quantize=False, + ) + pipeline.run() + """, + ) + rows, unresolved, diagnostics = docgen._scan_backend_pipeline_tests(tmp_path) + assert not unresolved + assert not diagnostics + row = rows["torch.ops.aten.masked_fill.Scalar"] + assert row.support_profiles == {"FP"} + assert row.dtypes == {"FP32"}, row.dtypes + finally: + docgen._activate_backend(original) + + +def test_vgf_docgen_masked_fill_fp16_data_not_bool_mask(tmp_path: Path) -> None: + """Explicit FP16 data with a BOOL mask must not gain FP32 or BOOL.""" + original = docgen.ACTIVE_BACKEND_KEY + try: + docgen._activate_backend("vgf") + _write_test_module( + tmp_path, + "backends/arm/test/ops/test_masked_fill.py", + """ + aten_op = "torch.ops.aten.masked_fill.Scalar" + class MaskedFill(torch.nn.Module): + def forward(self, x, mask, value): + return torch.masked_fill(x, mask, value) + + def make_case(): + return (MaskedFill(), ( + torch.rand(2, dtype=torch.float16), + torch.ones(2, dtype=torch.bool), 1.0, + )) + + test_modules = {"fp16": lambda: make_case()} + + @common.parametrize("test_module", test_modules) + def test_masked_fill_scalar_vgf_no_quant(test_module): + module, inputs = test_module() + VgfPipeline(module, inputs, aten_op=[], quantize=False) + """, + ) + rows, unresolved, _ = docgen._scan_backend_pipeline_tests(tmp_path) + assert not unresolved + assert rows["torch.ops.aten.masked_fill.Scalar"].dtypes == {"FP16"} + finally: + docgen._activate_backend(original) + + +def test_vgf_docgen_fp8_construction_buffer_is_not_fp32(tmp_path: Path) -> None: + """A separate FP8 operator test must not inherit its FP32 cast source.""" + original = docgen.ACTIVE_BACKEND_KEY + try: + docgen._activate_backend("vgf") + _write_test_module( + tmp_path, + "backends/arm/test/ops/test_fp8_no_false_fp32.py", + """ + test_data = { + "fp8": lambda: torch.randn(2).to(torch.float8_e4m3fn), + } + + @common.parametrize("test_data", test_data) + def test_relu_vgf_no_quant_fp8(test_data): + VgfPipelineFP8( + object(), test_data(), + aten_op="torch.ops.aten.relu.default", exir_op=[], + ) + """, + ) + rows, unresolved, _ = docgen._scan_backend_pipeline_tests(tmp_path) + assert not unresolved + assert rows["torch.ops.aten.relu.default"].dtypes == {"FP8E4M3"} + finally: + docgen._activate_backend(original) + + +def test_vgf_docgen_masks_in_opaque_fixture_preserve_fp32(tmp_path: Path) -> None: + """Unknown fixture signatures still find distinct non-converted FP32.""" + original = docgen.ACTIVE_BACKEND_KEY + try: + docgen._activate_backend("vgf") + _write_test_module( + tmp_path, + "backends/arm/test/ops/test_opaque_mask.py", + """ + cases = { + "default": lambda: ( + imported_model(), + (torch.rand(2), torch.ones(2, dtype=torch.bool)), + ), + } + + @common.parametrize("test_module", cases) + def test_add_vgf_no_quant(test_module): + model, inputs = test_module() + VgfPipeline( + model, inputs, + aten_op="torch.ops.aten.add.Tensor", exir_op=[], + quantize=False, + ) + """, + ) + rows, unresolved, _ = docgen._scan_backend_pipeline_tests(tmp_path) + assert not unresolved + assert "FP32" in rows["torch.ops.aten.add.Tensor"].dtypes + finally: + docgen._activate_backend(original) + + +# AACR Patchset 9: keep the parametrization name gates consistent with the +# existing _fp_is_input_parameter() classifier. + + +@pytest.mark.parametrize("parameter_name", ["input", "data", "inputs", "tensor", "x"]) +def test_docgen_vgf_fp8_direct_input_parameter_names( + tmp_path: Path, parameter_name: str +) -> None: + """Recognize FP8 dtype cases for ordinary input parameter names.""" + original = docgen.ACTIVE_BACKEND_KEY + try: + docgen._activate_backend("vgf") + _write_test_module( + tmp_path, + "backends/arm/test/ops/test_vgf_fp8_input_name.py", + f""" + @pytest.mark.parametrize( + "{parameter_name}", + [ + pytest.param(torch.float8_e4m3fn, id="e4m3"), + pytest.param(torch.float8_e5m2, id="e5m2"), + ], + ) + def test_relu_vgf_fp8_{parameter_name}({parameter_name}): + VgfPipelineFP8( + object(), {parameter_name}, + aten_op="torch.ops.aten.relu.default", exir_op=[], + ) + """, + ) + rows, unresolved, diagnostics = docgen._scan_backend_pipeline_tests(tmp_path) + assert not unresolved + assert not diagnostics + row = rows["torch.ops.aten.relu.default"] + assert row.support_profiles == {"FP"} + assert row.dtypes == {"FP8E4M3", "FP8E5M2"} + finally: + docgen._activate_backend(original) + + +def test_docgen_vgf_unresolved_plain_input_retains_fp32(tmp_path: Path) -> None: + """An opaque `input` case must participate in unresolved-case filtering.""" + original = docgen.ACTIVE_BACKEND_KEY + try: + docgen._activate_backend("vgf") + _write_test_module( + tmp_path, + "backends/arm/test/ops/test_vgf_unresolved_input_name.py", + """ + @pytest.mark.parametrize( + "input", + [ + pytest.param(imported_make_case(), id="opaque"), + pytest.param(torch.randn(2, dtype=torch.float16), id="fp16"), + ], + ) + def test_relu_vgf_no_quant(input): + VgfPipeline( + object(), input, + aten_op="torch.ops.aten.relu.default", exir_op=[], + quantize=False, + ) + """, + ) + rows, unresolved, diagnostics = docgen._scan_backend_pipeline_tests(tmp_path) + assert not unresolved + assert not diagnostics + assert rows["torch.ops.aten.relu.default"].dtypes == {"FP16", "FP32"} + finally: + docgen._activate_backend(original) + + +def test_docgen_vgf_fp8_input_name_excludes_auxiliary_dtype(tmp_path: Path) -> None: + """Select data dtype and ignore a named BOOL mask and expected output.""" + original = docgen.ACTIVE_BACKEND_KEY + try: + docgen._activate_backend("vgf") + _write_test_module( + tmp_path, + "backends/arm/test/ops/test_vgf_input_with_mask.py", + """ + @pytest.mark.parametrize( + "input,mask,expected", + [ + pytest.param( + torch.float8_e4m3fn, torch.bool, torch.float16, + ), + pytest.param( + torch.float8_e5m2, torch.bool, torch.bfloat16, + ), + ], + ) + def test_relu_vgf_fp8_with_mask(input, mask, expected): + VgfPipelineFP8( + object(), input, + aten_op="torch.ops.aten.relu.default", exir_op=[], + ) + """, + ) + rows, unresolved, diagnostics = docgen._scan_backend_pipeline_tests(tmp_path) + assert not unresolved + assert not diagnostics + assert rows["torch.ops.aten.relu.default"].dtypes == {"FP8E4M3", "FP8E5M2"} + finally: + docgen._activate_backend(original) diff --git a/docs/source/backends/arm-vgf/VGF_op_support.md b/docs/source/backends/arm-vgf/VGF_op_support.md index 91a47741411..964ee5636c9 100644 --- a/docs/source/backends/arm-vgf/VGF_op_support.md +++ b/docs/source/backends/arm-vgf/VGF_op_support.md @@ -6,7 +6,9 @@ This page lists VGF-supported PyTorch APIs and the dtype and quantization modes `8x8` means 8-bit activations and 8-bit weights. `16x8` means 16-bit activations and 8-bit weights. `8x4` means 8-bit activations and 4-bit weights. -Total supported PyTorch APIs: **163**. +`FP8E4M3` and `FP8E5M2` require their respective TOSA extensions (`fp8e4m3`, `fp8e5m2`); they are not enabled by the default VGF compile spec. + +Total supported PyTorch APIs: **164**. | PyTorch API | Support profile | DType | Quantization mode | | --- | --- | --- | --- | @@ -35,48 +37,48 @@ Total supported PyTorch APIs: **163**. | `torch.bitwise_right_shift` / `>>` | FP, INT | `FP32`, `INT8` | 8x8 | | `torch.bitwise_xor` / `^` | FP, INT | `FP32`, `INT8` | 8x8 | | `torch.bmm` | FP, INT | `FP32`, `INT8` | 8x8 | -| `torch.cat` | FP, INT | `FP32`, `FP16`, `BF16`, `INT8` | 8x8 | -| `torch.ceil` | FP, INT | `FP16`, `BF16`, `INT8` | 8x8 | +| `torch.cat` | FP, INT | `FP32`, `FP16`, `BF16`, `FP8E4M3`, `FP8E5M2`, `INT8` | 8x8 | +| `torch.ceil` | FP, INT | `FP32`, `FP16`, `BF16`, `INT8` | 8x8 | | `torch.clamp` | FP, INT | `FP32`, `FP16`, `BF16`, `INT8`, `INT16` | 8x8, 16x8 | | `torch.clone` / `torch.Tensor.clone` | FP, INT | `FP32`, `INT8` | 8x8 | | `torch.cond` | INT | `INT8` | 8x8 | | `torch.conv1d` | FP, INT | `FP32`, `INT8`, `INT4` | 8x8, 8x4 | -| `torch.conv2d` | FP, INT | `FP32`, `FP16`, `BF16`, `INT8`, `INT16`, `INT4` | 8x8, 8x4, 16x8 | -| `torch.conv3d` | FP, INT | `FP32`, `FP16`, `BF16`, `INT8`, `INT16`, `INT4` | 8x8, 8x4, 16x8 | -| `torch.conv_transpose2d` | FP, INT | `FP32`, `FP16`, `BF16`, `INT8`, `INT16`, `INT4` | 8x8, 8x4, 16x8 | -| `torch.cos` | FP, INT | `FP16`, `BF16`, `INT8` | 8x8 | +| `torch.conv2d` | FP, INT | `FP32`, `FP16`, `BF16`, `FP8E4M3`, `FP8E5M2`, `INT8`, `INT16`, `INT4` | 8x8, 8x4, 16x8 | +| `torch.conv3d` | FP, INT | `FP32`, `FP16`, `BF16`, `FP8E4M3`, `FP8E5M2`, `INT8`, `INT16`, `INT4` | 8x8, 8x4, 16x8 | +| `torch.conv_transpose2d` | FP, INT | `FP32`, `FP16`, `BF16`, `FP8E4M3`, `FP8E5M2`, `INT8`, `INT16`, `INT4` | 8x8, 8x4, 16x8 | +| `torch.cos` | FP, INT | `FP32`, `FP16`, `BF16`, `INT8` | 8x8 | | `torch.cosh` | FP, INT | `FP32`, `INT8` | 8x8 | | `torch.cumsum` | FP, INT | `FP32`, `INT8` | 8x8 | | `torch.div` | FP, INT | `FP32`, `INT8` | 8x8 | | `torch.div` / `/` | FP, INT | `FP32`, `INT8` | 8x8 | | `torch.einsum` | FP | `FP32` | - | -| `torch.eq` / `==` | FP, INT | `FP32`, `INT8`, `INT16` | 8x8, 16x8 | +| `torch.eq` / `==` | FP, INT | `FP32`, `FP16`, `BF16`, `INT8`, `INT16` | 8x8, 16x8 | | `torch.erf` | FP, INT | `FP32`, `FP16`, `BF16`, `INT8` | 8x8 | | `torch.erfinv` | FP, INT | `FP32`, `FP16`, `INT8` | 8x8 | -| `torch.exp` | FP, INT | `FP16`, `BF16`, `INT8` | 8x8 | +| `torch.exp` | FP, INT | `FP32`, `FP16`, `BF16`, `INT8` | 8x8 | | `torch.expm1` | FP, INT | `FP32`, `INT8` | 8x8 | | `torch.eye` | FP, INT | `FP32`, `INT8` | 8x8 | | `torch.fill_` | FP, INT | `FP32`, `INT8` | 8x8 | -| `torch.flip` | FP, INT | `FP32`, `INT8` | 8x8 | -| `torch.floor` | FP, INT | `FP16`, `BF16`, `INT8` | 8x8 | +| `torch.flip` | FP, INT | `FP32`, `FP8E4M3`, `FP8E5M2`, `INT8` | 8x8 | +| `torch.floor` | FP, INT | `FP32`, `FP16`, `BF16`, `INT8` | 8x8 | | `torch.floor_divide` / `//` | FP | `FP32` | - | | `torch.full` | FP, INT | `FP32`, `INT8` | 8x8 | | `torch.full_like` | FP, INT | `FP32`, `INT8` | 8x8 | -| `torch.gather` | FP, INT | `FP32`, `FP16`, `BF16`, `INT8`, `BOOL` | 8x8 | -| `torch.ge` / `>=` | FP, INT | `FP32`, `INT8`, `INT16` | 8x8, 16x8 | +| `torch.gather` | FP, INT | `FP32`, `FP16`, `BF16`, `FP8E4M3`, `FP8E5M2`, `INT8`, `BOOL` | 8x8 | +| `torch.ge` / `>=` | FP, INT | `FP32`, `FP16`, `BF16`, `INT8`, `INT16` | 8x8, 16x8 | | `torch.grid_sampler` | FP, INT | `FP32`, `INT8` | 8x8 | | `torch.grid_sampler_2d` | FP, INT | `FP32`, `INT8` | 8x8 | | `torch.group_norm` | FP | `FP32` | - | -| `torch.gt` / `>` | FP, INT | `FP32`, `INT8`, `INT16` | 8x8, 16x8 | +| `torch.gt` / `>` | FP, INT | `FP32`, `FP16`, `BF16`, `INT8`, `INT16` | 8x8, 16x8 | | `torch.index_put_` | INT | `INT8` | 8x8 | -| `torch.index_select` | FP, INT | `FP32`, `FP16`, `BF16`, `INT8`, `BOOL` | 8x8 | +| `torch.index_select` | FP, INT | `FP32`, `FP16`, `BF16`, `FP8E4M3`, `FP8E5M2`, `INT8`, `BOOL` | 8x8 | | `torch.isinf` | FP | `FP32` | - | | `torch.isnan` | FP | `FP32` | - | | `torch.layer_norm` | FP, INT | `FP32`, `INT8` | 8x8 | -| `torch.le` / `<=` | FP, INT | `FP32`, `INT8`, `INT16` | 8x8, 16x8 | +| `torch.le` / `<=` | FP, INT | `FP32`, `FP16`, `INT8`, `INT16` | 8x8, 16x8 | | `torch.linalg.vector_norm` | FP, INT | `FP32`, `INT8` | 8x8 | | `torch.linspace` | FP, INT | `FP32`, `INT8` | 8x8 | -| `torch.log` | FP, INT | `FP16`, `BF16`, `INT8` | 8x8 | +| `torch.log` | FP, INT | `FP32`, `FP16`, `BF16`, `INT8` | 8x8 | | `torch.log10` | INT | `INT8` | 8x8 | | `torch.log1p` | FP, INT | `FP32`, `INT8` | 8x8 | | `torch.log_softmax` | FP, INT | `FP32`, `INT8` | 8x8 | @@ -85,12 +87,12 @@ Total supported PyTorch APIs: **163**. | `torch.logical_or` | FP, INT | `FP32`, `INT8` | 8x8 | | `torch.logical_xor` | FP, INT | `FP32`, `INT8` | 8x8 | | `torch.logit` | FP, INT | `FP32`, `INT8` | 8x8 | -| `torch.lt` / `<` | FP, INT | `FP32`, `INT8`, `INT16` | 8x8, 16x8 | +| `torch.lt` / `<` | FP, INT | `FP32`, `FP16`, `INT8`, `INT16` | 8x8, 16x8 | | `torch.masked_fill` / `torch.Tensor.masked_fill` | FP, INT | `FP32`, `INT8` | 8x8 | -| `torch.matmul` / `@` | FP, INT | `FP32`, `INT8` | 8x8 | +| `torch.matmul` / `@` | FP, INT | `FP32`, `FP16`, `BF16`, `FP8E4M3`, `FP8E5M2`, `INT8` | 8x8 | | `torch.max` | FP | `FP32`, `FP16`, `BF16` | - | | `torch.max_pool1d` | FP, INT | `FP32`, `INT8` | 8x8 | -| `torch.max_pool2d` | FP, INT | `FP32`, `FP16`, `BF16`, `INT8`, `INT16` | 8x8, 16x8 | +| `torch.max_pool2d` | FP, INT | `FP32`, `FP16`, `BF16`, `FP8E4M3`, `FP8E5M2`, `INT8`, `INT16` | 8x8, 16x8 | | `torch.maximum` | FP, INT | `FP32`, `FP16`, `BF16`, `INT8`, `INT16` | 8x8, 16x8 | | `torch.mean` | FP, INT | `FP32`, `INT8` | 8x8 | | `torch.min` | FP | `FP32`, `FP16`, `BF16` | - | @@ -101,16 +103,16 @@ Total supported PyTorch APIs: **163**. | `torch.neg` / `unary -` | FP, INT | `FP32`, `INT8`, `INT16` | 8x8, 16x8 | | `torch.nn.AdaptiveAvgPool1d` / `torch.nn.functional.adaptive_avg_pool1d` | FP | `FP32` | - | | `torch.nn.AdaptiveAvgPool2d` / `torch.nn.functional.adaptive_avg_pool2d` | FP | `FP32` | - | -| `torch.nn.AvgPool2d` / `torch.nn.functional.avg_pool2d` | FP, INT | `FP32`, `INT8`, `INT16` | 8x8, 16x8 | +| `torch.nn.AvgPool2d` / `torch.nn.functional.avg_pool2d` | FP, INT | `FP32`, `FP16`, `BF16`, `FP8E4M3`, `FP8E5M2`, `INT8`, `INT16` | 8x8, 16x8 | | `torch.nn.BatchNorm2d` / `torch.nn.functional.batch_norm` | FP, INT | `FP32`, `INT8` | 8x8 | | `torch.nn.CELU` / `torch.nn.functional.celu` | FP | `FP32` | - | -| `torch.nn.Conv2d` / `torch.nn.functional.conv2d` | FP, INT | `FP32`, `FP16`, `BF16`, `INT8`, `INT16`, `INT4` | 8x8, 8x4, 16x8 | +| `torch.nn.Conv2d` / `torch.nn.functional.conv2d` | FP, INT | `FP32`, `FP16`, `BF16`, `FP8E4M3`, `FP8E5M2`, `INT8`, `INT16`, `INT4` | 8x8, 8x4, 16x8 | | `torch.nn.ELU` / `torch.nn.functional.elu` | FP, INT | `FP32`, `INT8` | 8x8 | -| `torch.nn.Embedding` / `torch.nn.functional.embedding` | FP, INT | `FP32`, `INT8` | 8x8 | -| `torch.nn.functional.pad` | FP, INT | `FP32`, `FP16`, `BF16`, `INT8`, `INT16` | 8x8, 16x8 | +| `torch.nn.Embedding` / `torch.nn.functional.embedding` | FP, INT | `FP32`, `FP8E4M3`, `FP8E5M2`, `INT8` | 8x8 | +| `torch.nn.functional.pad` | FP, INT | `FP32`, `FP16`, `BF16`, `FP8E4M3`, `FP8E5M2`, `INT8`, `INT16` | 8x8, 16x8 | | `torch.nn.functional.scaled_dot_product_attention` | FP, INT | `FP32`, `INT8` | 8x8 | | `torch.nn.functional.softplus` | INT | `INT8` | 8x8 | -| `torch.nn.GELU` / `torch.nn.functional.gelu` | FP, INT | `FP32`, `BF16`, `INT8` | 8x8 | +| `torch.nn.GELU` / `torch.nn.functional.gelu` | FP, INT | `FP32`, `FP16`, `BF16`, `INT8` | 8x8 | | `torch.nn.GroupNorm` / `torch.nn.functional.group_norm` | FP, INT | `FP32`, `INT8` | 8x8 | | `torch.nn.Hardsigmoid` / `torch.nn.functional.hardsigmoid` | FP, INT | `FP32`, `INT8` | 8x8 | | `torch.nn.Hardswish` / `torch.nn.functional.hardswish` | FP, INT | `FP32`, `INT8` | 8x8 | @@ -123,27 +125,27 @@ Total supported PyTorch APIs: **163**. | `torch.nn.SiLU` / `torch.nn.functional.silu` | FP, INT | `FP32`, `INT8` | 8x8 | | `torch.nn.Softmax` / `torch.nn.functional.softmax` | FP, INT | `FP32`, `INT8` | 8x8 | | `torch.ones` | FP, INT | `FP32`, `INT8` | 8x8 | -| `torch.permute` / `torch.Tensor.permute` | FP, INT | `FP32`, `FP16`, `BF16`, `INT8` | 8x8 | +| `torch.permute` / `torch.Tensor.permute` | FP, INT | `FP32`, `FP16`, `BF16`, `FP8E4M3`, `FP8E5M2`, `INT8` | 8x8 | | `torch.pixel_shuffle` | FP, INT | `FP32`, `INT8` | 8x8 | | `torch.pixel_unshuffle` | FP, INT | `FP32`, `INT8` | 8x8 | | `torch.pow` / `**` | FP, INT | `FP32`, `FP16`, `BF16`, `INT8` | 8x8 | -| `torch.prod` | FP, INT | `FP16`, `BF16`, `INT8` | 8x8 | -| `torch.reciprocal` | FP, INT | `FP16`, `BF16`, `INT8` | 8x8 | +| `torch.prod` | FP, INT | `FP32`, `FP16`, `BF16`, `INT8` | 8x8 | +| `torch.reciprocal` | FP, INT | `FP32`, `FP16`, `BF16`, `INT8` | 8x8 | | `torch.relu` / `torch.nn.ReLU` | FP, INT | `FP32`, `INT8` | 8x8 | | `torch.remainder` | FP, INT | `FP32`, `INT8` | 8x8 | -| `torch.repeat_interleave` | FP, INT | `FP16`, `BF16`, `INT8`, `INT16`, `BOOL` | 8x8, 16x8 | +| `torch.repeat_interleave` | FP, INT | `FP32`, `FP16`, `BF16`, `INT8`, `INT16`, `BOOL` | 8x8, 16x8 | | `torch.roll` | FP, INT | `FP32`, `FP16`, `BF16`, `INT8` | 8x8 | | `torch.round` | FP, INT | `FP32`, `FP16`, `BF16`, `INT8` | 8x8 | | `torch.rsqrt` | FP, INT | `FP32`, `INT8`, `INT16` | 8x8, 16x8 | | `torch.rsub` | FP | `FP32` | - | | `torch.scalar_tensor` | FP, INT | `FP32`, `INT8` | 8x8 | | `torch.select` / `torch.Tensor.select` | FP, INT | `FP32`, `INT8` | 8x8 | -| `torch.select_scatter` | FP | `FP32` | - | +| `torch.select_scatter` | FP | `FP32`, `FP16`, `BF16` | - | | `torch.sigmoid` / `torch.nn.Sigmoid` | FP, INT | `FP32`, `FP16`, `BF16`, `INT8` | 8x8 | | `torch.sign` | FP, INT | `FP32`, `INT8` | 8x8 | -| `torch.sin` | FP, INT | `FP16`, `BF16`, `INT8` | 8x8 | +| `torch.sin` | FP, INT | `FP32`, `FP16`, `BF16`, `INT8` | 8x8 | | `torch.sinh` | FP, INT | `FP32`, `INT8` | 8x8 | -| `torch.slice_scatter` | FP | `FP32` | - | +| `torch.slice_scatter` | FP | `FP32`, `FP16`, `BF16`, `FP8E4M3`, `FP8E5M2` | - | | `torch.softmax` | FP, INT | `FP32`, `INT8` | 8x8 | | `torch.split` / `torch.Tensor.split` | FP, INT | `FP32`, `INT8` | 8x8 | | `torch.sqrt` | FP | `FP32` | - | @@ -151,18 +153,19 @@ Total supported PyTorch APIs: **163**. | `torch.squeeze` / `torch.Tensor.squeeze` | FP, INT | `FP32`, `INT8` | 8x8 | | `torch.stack` | FP, INT | `FP32`, `INT8` | 8x8 | | `torch.sub` / `-` | FP, INT | `FP32`, `FP16`, `BF16`, `INT8`, `INT16` | 8x8, 16x8 | -| `torch.sum` | FP, INT | `FP16`, `BF16`, `INT8` | 8x8 | +| `torch.sum` | FP, INT | `FP32`, `FP16`, `BF16`, `INT8` | 8x8 | | `torch.t` / `torch.Tensor.t` | FP, INT | `FP32`, `INT8` | 8x8 | | `torch.tan` | FP, INT | `FP32`, `INT8` | 8x8 | -| `torch.tanh` / `torch.nn.Tanh` | FP, INT | `FP16`, `BF16`, `INT8` | 8x8 | -| `torch.Tensor.__getitem__` / `tensor indexing` | FP, INT | `FP32`, `FP16`, `BF16`, `INT8` | 8x8 | -| `torch.Tensor.__getitem__` / `tensor slicing` | FP, INT | `FP32`, `FP16`, `BF16`, `INT8` | 8x8 | -| `torch.Tensor.__setitem__` / `tensor indexing assignment` | FP, INT | `FP32`, `INT8` | 8x8 | +| `torch.tanh` / `torch.nn.Tanh` | FP, INT | `FP32`, `FP16`, `BF16`, `INT8` | 8x8 | +| `torch.Tensor.__getitem__` / `tensor indexing` | FP, INT | `FP32`, `FP16`, `BF16`, `FP8E4M3`, `FP8E5M2`, `INT8` | 8x8 | +| `torch.Tensor.__getitem__` / `tensor slicing` | FP, INT | `FP32`, `FP16`, `BF16`, `FP8E4M3`, `FP8E5M2`, `INT8` | 8x8 | +| `torch.Tensor.__setitem__` / `tensor indexing assignment` | FP, INT | `FP32`, `FP16`, `BF16`, `FP8E4M3`, `FP8E5M2`, `INT8` | 8x8 | | `torch.Tensor.copy_` | FP, INT | `FP32`, `INT8` | 8x8 | | `torch.Tensor.expand` | FP, INT | `FP32`, `INT8` | 8x8 | -| `torch.Tensor.repeat` | FP, INT | `FP32`, `FP16`, `BF16`, `INT8`, `INT16`, `BOOL` | 8x8, 16x8 | -| `torch.Tensor.unfold` | FP, INT | `FP32`, `FP16`, `BF16`, `INT8`, `BOOL` | 8x8 | -| `torch.Tensor.view` | FP, INT | `FP32`, `FP16`, `INT8` | 8x8 | +| `torch.Tensor.repeat` | FP, INT | `FP32`, `FP16`, `BF16`, `FP8E4M3`, `FP8E5M2`, `INT8`, `INT16`, `BOOL` | 8x8, 16x8 | +| `torch.Tensor.to` | FP | `FP8E4M3`, `FP8E5M2` | - | +| `torch.Tensor.unfold` | FP, INT | `FP32`, `FP16`, `BF16`, `FP8E4M3`, `FP8E5M2`, `INT8`, `BOOL` | 8x8 | +| `torch.Tensor.view` | FP, INT | `FP32`, `FP16`, `FP8E4M3`, `FP8E5M2`, `INT8` | 8x8 | | `torch.topk` | FP | `FP32`, `FP16` | - | | `torch.transpose` / `torch.Tensor.transpose` | FP, INT | `FP32`, `INT8` | 8x8 | | `torch.tril` | FP | `FP32` | - | @@ -170,6 +173,6 @@ Total supported PyTorch APIs: **163**. | `torch.unflatten` | FP, INT | `FP32`, `INT8` | 8x8 | | `torch.unsqueeze` / `torch.Tensor.unsqueeze` | FP, INT | `FP32`, `INT8` | 8x8 | | `torch.var` | FP | `FP32` | - | -| `torch.where` | FP, INT | `FP32`, `INT8`, `INT16` | 8x8, 16x8 | +| `torch.where` | FP, INT | `FP32`, `FP16`, `BF16`, `INT8`, `INT16` | 8x8, 16x8 | | `torch.while_loop` | INT | `INT8` | 8x8 | | `torch.zeros` | FP, INT | `FP32`, `INT8` | 8x8 | From a057bb41ad053f601cbc2865c9b6715fcace6756 Mon Sep 17 00:00:00 2001 From: RJ Ascani Date: Tue, 3 Nov 2026 08:06:48 -0800 Subject: [PATCH 011/128] Separate ESP32 CI runs by commit (#24472) ### Summary Every push to `main` currently puts the ESP32 workflow in the same concurrency group, so a new commit cancels the previous commit's jobs. In [this job](https://github.com/pytorch/executorch/actions/runs/41514087917/job/125514346213), the smoke test and all 16 optimized model cases passed, but the job was marked cancelled when another push arrived. Use the concurrency group already shared by the other backend test workflows. Including the commit SHA for branch runs lets different `main` commits finish independently. The group also separates scheduled and manually triggered runs. Authored with AI assistance using OpenAI Codex. ### Test plan `git diff --check` passed. Compared the complete `concurrency` block with all ten `test-backend-*.yml` workflows and verified that it matches each exactly. No emulator tests were rerun for this workflow scheduling change. --- .github/workflows/test-espressif.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-espressif.yml b/.github/workflows/test-espressif.yml index 823b6a804c2..3f49ee17488 100644 --- a/.github/workflows/test-espressif.yml +++ b/.github/workflows/test-espressif.yml @@ -19,7 +19,7 @@ on: workflow_dispatch: concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref_name }}-${{ github.ref_type == 'branch' && github.sha }}-${{ github.event_name == 'workflow_dispatch' }}-${{ github.event_name == 'schedule' }} cancel-in-progress: true jobs: From 7da27ca5cbe8acde5b587c7279fea97bf386bb38 Mon Sep 17 00:00:00 2001 From: RJ Ascani Date: Tue, 3 Nov 2026 08:28:02 -0800 Subject: [PATCH 012/128] Run Espressif operator-library cases in optimized S3 CI (#24313) ### Summary Run the shared Espressif operator-library implementation cases in the existing optimized ESP32-S3 CI job, using its SDK setup and runtime build. Include the portable sine kernel used by the multiply tests' float branches and verify that the operator firmware selects optimized ESP-NN kernels. Stop at the first operator failure so subsequent cases cannot overwrite its firmware and logs. Collect the bundle, SDK configuration, ELF, merged image, and test logs with the existing CI artifacts. This builds on the multiply implementation and ESP-IDF test harness in #24311. ### Test plan The complete optimized S3 script passed with a non-editable installed Python package: three smoke inferences, sixteen delegate fixtures, and all eleven shared operator implementation cases. SDK configuration confirmed optimized kernels, and the workflow collected its firmware and test artifacts. The preceding kernel PR supplies the explicit checkout path fix. This PR remains the one-file CI integration change. The full rebased stack also passed 153 frontend tests. ```bash ESPRESSIF_NN_KERNELS=optimized bash .ci/scripts/test_espressif.sh ``` Authored with AI assistance using Codex. --- .ci/scripts/test_espressif.sh | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/.ci/scripts/test_espressif.sh b/.ci/scripts/test_espressif.sh index 0fe73ed1e55..271aed2a3af 100644 --- a/.ci/scripts/test_espressif.sh +++ b/.ci/scripts/test_espressif.sh @@ -21,6 +21,7 @@ build_dir=$(cd "${build_dir}" && pwd) cd "${et_root}" collect_artifacts() { + local file operator_dir artifact_dir if [[ -n "${RUNNER_ARTIFACT_DIR:-}" ]]; then mkdir -p "${RUNNER_ARTIFACT_DIR}/esp32s3" for file in "${build_dir}"/*.log "${build_dir}"/*.bpte \ @@ -35,6 +36,18 @@ collect_artifacts() { cp "${file}" "${RUNNER_ARTIFACT_DIR}/esp32s3/" fi done + for operator_dir in "${build_dir}"/operator-library-*; do + [[ -d "${operator_dir}" ]] || continue + artifact_dir="${RUNNER_ARTIFACT_DIR}/esp32s3/${operator_dir##*/}" + mkdir -p "${artifact_dir}" + for file in "${operator_dir}"/*.log "${operator_dir}"/*.bpte \ + "${operator_dir}/sdkconfig" "${operator_dir}"/*.elf \ + "${operator_dir}/merged-binary.bin"; do + if [[ -f "${file}" ]]; then + cp "${file}" "${artifact_dir}/" + fi + done + done fi } trap collect_artifacts EXIT @@ -54,6 +67,9 @@ python examples/espressif/export_espnn_test_programs.py \ # runs inline without pthreadpool, which needs POSIX calls ESP-IDF lacks. espnn_ops=$(tr -d '\n' < "${build_dir}/espnn-tests/operators.txt" \ | tr ',' '\n' | grep -v '^quantized_decomposed::' | paste -sd, -) +if [[ "${espnn_kernels}" == optimized ]]; then + espnn_ops="${espnn_ops},aten::sin.out" +fi cmake --preset esp-baremetal -G Ninja -B "${build_dir}/runtime" \ -DCMAKE_TOOLCHAIN_FILE="${IDF_PATH}/tools/cmake/toolchain-esp32s3.cmake" \ @@ -136,3 +152,16 @@ import json, sys for entry in json.load(open(sys.argv[1])): print(entry["name"], entry["atol"], entry["rtol"]) ' "${build_dir}/espnn-tests/manifest.json") + +if [[ "${espnn_kernels}" == optimized ]]; then + # Stop on failure so later cases cannot overwrite its firmware and logs. + ESPRESSIF_BUILD_DIR="${build_dir}" python -m pytest \ + -x -v --tb=short backends/espressif/test/ops -k implementation \ + 2>&1 | tee "${build_dir}/operator-library-tests.log" + for sdkconfig in "${build_dir}"/operator-library-*/sdkconfig; do + if ! grep -qx 'CONFIG_NN_OPTIMIZED=y' "${sdkconfig}"; then + echo "Expected CONFIG_NN_OPTIMIZED=y in ${sdkconfig}" >&2 + exit 1 + fi + done +fi From 249237c5ee37733a479bee612a2fa5b3c44e7386 Mon Sep 17 00:00:00 2001 From: Mateusz Sluszniak <56299341+msluszniak@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:11:41 +0100 Subject: [PATCH 013/128] XNNPACK: read inputs with a non-default dim order in their memory layout (#24081) ### Summary A delegate input whose dim order is neither contiguous nor channels-last (e.g. `(1, 0, 2)` from a transposed example input) is serialized with its logical sizes, but `XNNExecutor` reshapes it with sizes in dim order, so execution fails with `xnn_status_invalid_parameter` or reads swapped axes. Define such inputs in memory order and transpose back at the start of the graph. #14735 was the constant-tensor side of this. ### Test plan New `ops/test_io_dim_order.py` tests fail before, pass after. `backends/xnnpack/test/{ops,passes,serialization}` unchanged. cc @GregoryComer @digantdesai @cbilgin @JakeStevens @nil-is-all --- .../channels_last_tagged_reshape_pass.py | 8 +- .../xnnpack/test/ops/test_io_dim_order.py | 184 ++++++++++++++++++ backends/xnnpack/xnnpack_preprocess.py | 117 +++++++++-- 3 files changed, 292 insertions(+), 17 deletions(-) create mode 100644 backends/xnnpack/test/ops/test_io_dim_order.py diff --git a/backends/xnnpack/_passes/channels_last_tagged_reshape_pass.py b/backends/xnnpack/_passes/channels_last_tagged_reshape_pass.py index d5d1dc3ef3e..3a950b09c59 100644 --- a/backends/xnnpack/_passes/channels_last_tagged_reshape_pass.py +++ b/backends/xnnpack/_passes/channels_last_tagged_reshape_pass.py @@ -127,7 +127,7 @@ def is_nhwc_node(node: torch.fx.Node) -> bool: if len(quantize_node.all_input_nodes) > 0: actual_node = quantize_node.args[0] if actual_node.op == "placeholder": - return ChannelsLastTaggedReshapePass._is_nhwc_tensor( + return ChannelsLastTaggedReshapePass.is_nhwc_tensor( actual_node.meta["val"][0] ) else: @@ -144,7 +144,7 @@ def is_nchw_node(node: torch.fx.Node) -> bool: if len(quantize_node.all_input_nodes) > 0: actual_node = quantize_node.args[0] if actual_node.op == "placeholder": - return not ChannelsLastTaggedReshapePass._is_nhwc_tensor( + return not ChannelsLastTaggedReshapePass.is_nhwc_tensor( actual_node.meta["val"][0] ) else: @@ -155,7 +155,7 @@ def is_nchw_node(node: torch.fx.Node) -> bool: return not ChannelsLastTaggedReshapePass.is_nhwc_node(node) @staticmethod - def _is_nhwc_tensor(tensor: torch.Tensor) -> bool: + def is_nhwc_tensor(tensor: torch.Tensor) -> bool: nhwc = tensor.is_contiguous(memory_format=torch.channels_last) nchw = tensor.is_contiguous() # if both are true false @@ -172,7 +172,7 @@ def _is_nhwc_tensor(tensor: torch.Tensor) -> bool: return nhwc def _is_nhwc(self, tensor: torch.Tensor) -> bool: - return ChannelsLastTaggedReshapePass._is_nhwc_tensor(tensor) + return ChannelsLastTaggedReshapePass.is_nhwc_tensor(tensor) def requires_nhwc_input(self, node: torch.fx.Node) -> bool: return node.target in self.memory_sensitive_ops_nhwc diff --git a/backends/xnnpack/test/ops/test_io_dim_order.py b/backends/xnnpack/test/ops/test_io_dim_order.py new file mode 100644 index 00000000000..a9bf65088b0 --- /dev/null +++ b/backends/xnnpack/test/ops/test_io_dim_order.py @@ -0,0 +1,184 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import unittest + +import torch +from executorch.backends.xnnpack.partition.xnnpack_partitioner import XnnpackPartitioner +from executorch.backends.xnnpack.quantizer.xnnpack_quantizer import ( + get_symmetric_quantization_config, + XNNPACKQuantizer, +) +from executorch.backends.xnnpack.test.tester import Quantize, Tester +from executorch.exir import EdgeCompileConfig, to_edge +from executorch.exir.passes.quantize_io_pass import ( + get_config_method_name, + QuantizeInputs, +) +from executorch.exir.tensor import dim_order_from_stride +from executorch.extension.pybindings.portable_lib import ( + _load_for_executorch_from_buffer, +) +from torchao.quantization.pt2e.quantize_pt2e import convert_pt2e, prepare_pt2e + + +class TestIODimOrder(unittest.TestCase): + """Delegate inputs and outputs whose dim order is neither contiguous nor NHWC.""" + + def setUp(self): + torch._dynamo.reset() + + class BMM(torch.nn.Module): + def forward(self, x, y): + return torch.bmm(x, y) + + class Add(torch.nn.Module): + def forward(self, x, y): + return x + y + + class Linear(torch.nn.Module): + def __init__(self): + super().__init__() + self.linear = torch.nn.Linear(16, 8) + + def forward(self, x): + return self.linear(x) + + class AddConv(torch.nn.Module): + def __init__(self): + super().__init__() + self.conv = torch.nn.Conv2d(3, 8, 3, padding=1) + + def forward(self, x, y): + return y + self.conv(x) + + @staticmethod + def _dim_order(tensor): + # The order the runtime uses, which breaks size-1 ties unlike dim_order() + return tuple(dim_order_from_stride(tensor.stride(), tuple(tensor.shape))) + + def _test(self, module, inputs, dynamic_shapes=None): + # The harness only generates contiguous or channels-last random inputs, + # so each run's inputs are given explicitly. + tester = Tester(module, inputs[0], dynamic_shapes=dynamic_shapes) + tester.export().to_edge_transform_and_lower().check_count( + {"torch.ops.higher_order.executorch_call_delegate": 1} + ).to_executorch().serialize() + for run_inputs in inputs: + tester.run_method_and_compare_outputs(inputs=run_inputs) + + @staticmethod + def _permuted(n=8): + # Dim order (2, 0, 1), which is not its own inverse, so a transpose in + # the wrong direction fails. + return torch.randn(16, 4, n).permute(1, 2, 0) + + def test_fp32_bmm_permuted_input(self): + x = self._permuted() + self.assertEqual(x.dim_order(), (2, 0, 1)) + self._test(self.BMM(), [(x, torch.randn(4, 16, 3))]) + + def test_fp32_bmm_permuted_input_dynamic(self): + n = torch.export.Dim("n", min=2, max=8) + self._test( + self.BMM(), + [(self._permuted(size), torch.randn(4, 16, 3)) for size in (8, 2, 5)], + dynamic_shapes=({1: n}, None), + ) + + def test_fp32_add_transposed_input_and_output(self): + x, y = torch.randn(1, 8, 4, 16).transpose(1, 2), torch.randn(1, 4, 8, 16) + self.assertEqual(self._dim_order(x), (0, 2, 1, 3)) + self.assertEqual(self._dim_order(self.Add()(x, y)), (0, 2, 1, 3)) + self._test(self.Add(), [(x, y)]) + + def test_fp32_add_transposed_size_one_dims(self): + x = torch.randn(1, 8, 1, 16).transpose(1, 2) + self.assertEqual(x.dim_order(), (0, 2, 1, 3)) + self.assertEqual(self._dim_order(x), (0, 1, 2, 3)) + self._test(self.Add(), [(x, torch.randn(1, 1, 8, 16))]) + + def test_fp32_conv_feeds_transposed_output(self): + # The add is NHWC after the conv, so the XNNPACK passes copy it back to + # contiguous before the output, while the runtime keeps y's dim order. + module = self.AddConv().eval() + x, y = torch.randn(1, 3, 8, 16), torch.randn(1, 8, 8, 16).transpose(1, 2) + self.assertEqual(self._dim_order(module(x, y)), (0, 2, 1, 3)) + self._test(module, [(x, y)]) + + def test_fp32_add_channels_last_3d(self): + x = torch.randn(1, 3, 4, 5, 6).to(memory_format=torch.channels_last_3d) + self._test(self.Add(), [(x, torch.randn(1, 3, 4, 5, 6))]) + + def test_qd8_linear_permuted_input(self): + # The external stays fp32 and is quantized after the transpose. + torch.manual_seed(0) + module, x = self.Linear().eval(), self._permuted() + config = get_symmetric_quantization_config(is_per_channel=True, is_dynamic=True) + + def run(example): + tester = Tester(module, (example,)) + tester.quantize(Quantize(quantization_config=config)).export() + tester.to_edge_transform_and_lower().check_count( + {"torch.ops.higher_order.executorch_call_delegate": 1} + ).to_executorch().serialize() + method = _load_for_executorch_from_buffer(tester.get_artifact()) + return method.run_method("forward", (x,))[0] + + # The q/dq reference quantizes per tensor and XNNPACK per token, so + # compare with the program exported from a contiguous input. + torch.testing.assert_close(run(x), run(x.contiguous()), atol=0, rtol=0) + + def test_qs8_add_quantized_permuted_input(self): + torch.manual_seed(0) + x, y = self._permuted(), torch.randn(4, 8, 16) + quantizer = XNNPACKQuantizer() + quantizer.set_global(get_symmetric_quantization_config()) + module = prepare_pt2e( + torch.export.export(self.Add().eval(), (x, y), strict=True).module(), + quantizer, + ) + module(x, y) + module = convert_pt2e(module) + edge = to_edge( + torch.export.export(module, (x, y), strict=True), + compile_config=EdgeCompileConfig(_check_ir_validity=False), + ) + # Take x already quantized, so the delegate input itself is quantized. + edge = edge.transform( + [QuantizeInputs(edge, quantized_inputs_idx=[0], method_name="forward")] + ) + program = edge.to_backend(XnnpackPartitioner()).to_executorch() + graph = program.exported_program().graph + delegates = [ + n + for n in graph.nodes + if n.target == torch.ops.higher_order.executorch_call_delegate + ] + self.assertEqual(len(delegates), 1) + x_node = next(n for n in graph.nodes if n.op == "placeholder") + self.assertIn(x_node, delegates[0].args) + self.assertEqual(x_node.meta["val"].dtype, torch.int8) + + qparams = edge._config_methods + quantized_x = torch.ops.quantized_decomposed.quantize_per_tensor.default( + x, + *( + qparams[get_config_method_name("forward", "input", 0, key)] + for key in ("scale", "zp", "quant_min", "quant_max") + ), + torch.int8, + ) + out = _load_for_executorch_from_buffer(program.buffer).run_method( + "forward", (quantized_x, y) + )[0] + # XNNPACK may round the requantized sum one step away from the reference. + output_scale = next( + n.args[1] + for n in reversed(module.graph.nodes) + if n.target == torch.ops.quantized_decomposed.quantize_per_tensor.default + ) + torch.testing.assert_close(out, module(x, y), atol=1.5 * output_scale, rtol=0) diff --git a/backends/xnnpack/xnnpack_preprocess.py b/backends/xnnpack/xnnpack_preprocess.py index cdceb8a90a1..b0fe9f3817d 100644 --- a/backends/xnnpack/xnnpack_preprocess.py +++ b/backends/xnnpack/xnnpack_preprocess.py @@ -5,18 +5,26 @@ # LICENSE file in the root directory of this source tree. import logging -from dataclasses import dataclass -from typing import Dict, final, List +from dataclasses import dataclass, replace +from typing import Dict, final, List, Tuple import torch from executorch.backends.xnnpack._passes import XNNPACKPassManager +from executorch.backends.xnnpack._passes.channels_last_tagged_reshape_pass import ( + ChannelsLastTaggedReshapePass, +) from executorch.backends.xnnpack._passes.convert_to_linear import ConvertToLinearPass from executorch.backends.xnnpack.operators.node_visitor import get_node_visitors from executorch.backends.xnnpack.serialization.xnnpack_graph_schema import ( ConstantDataOffset, + PerTensorQuant, XNNGraph, + XNNQuantizedTensorValue, + XNNStaticTranspose, + XNode, + XValue, ) from executorch.backends.xnnpack.serialization.xnnpack_graph_serialize import ( serialize_xnnpack_binary, @@ -25,16 +33,18 @@ from executorch.backends.xnnpack.utils.utils import is_param_node from executorch.backends.xnnpack.utils.xnnpack_constants import ( + XNN_INVALID_VALUE_ID, XNN_VALUE_FLAG_EXTERNAL_INPUT, XNN_VALUE_FLAG_EXTERNAL_OUTPUT, ) from executorch.exir._serialize._named_data_store import NamedDataStore - from executorch.exir.backend.backend_details import ( BackendDetails, CompileSpec, PreprocessResult, ) + +from executorch.exir.tensor import dim_order_from_stride from executorch.exir.verification.verifier import EXIREdgeDialectVerifier from torch.export.exported_program import ExportedProgram @@ -83,20 +93,94 @@ def generate_node_to_external_map( return node_to_external_map -def assert_default_dim_order(edge_graph_module: torch.fx.GraphModule) -> None: - for node in edge_graph_module.graph.nodes: - if node.op != "placeholder": +def get_external_dim_orders( + exported_program: ExportedProgram, +) -> List[Tuple[int, ...]]: + """ + The dim order the runtime gives each external, in external id order (inputs, + then outputs). Read before the XNNPACK passes, which can change an output's + layout, and from strides like the emitter. NHWC externals are left to + ChannelsLastTaggedReshapePass, so they get the default order here. + """ + graph = exported_program.graph_module.graph + nodes = [ + node + for node in graph.nodes + if node.op == "placeholder" and not is_param_node(exported_program, node) + ] + nodes += list(graph.output_node().args[0]) + dim_orders = [] + for node in nodes: + val = node.meta.get("val", None) + if val is None: + raise RuntimeError(f"XNNPACK external {node} has no value") + if not isinstance(val, torch.Tensor): + # Symbolic sizes have no layout. + dim_orders.append(()) + elif ChannelsLastTaggedReshapePass.is_nhwc_tensor(val): + dim_orders.append(tuple(range(val.dim()))) + else: + dim_orders.append( + tuple(int(d) for d in dim_order_from_stride(val.stride(), val.shape)) + ) + return dim_orders + + +def define_externals_in_memory_order( + xnn_graph: XNNGraph, dim_orders: List[Tuple[int, ...]] +) -> None: + """ + XNNExecutor shapes external tensors in dim order, so declare the ones whose + dim order is not the default in memory order, transposed to and from the + logical layout the graph is built in. + """ + prologue, epilogue = [], [] + for xvalue in list(xnn_graph.xvalues): + union = xvalue.xvalue_union + quantized = isinstance(union, XNNQuantizedTensorValue) + logical = union.tensor_value if quantized else union + is_input = logical.flags & XNN_VALUE_FLAG_EXTERNAL_INPUT + if not is_input and not logical.flags & XNN_VALUE_FLAG_EXTERNAL_OUTPUT: + continue + dim_order = dim_orders[logical.external_id] + if dim_order == tuple(range(len(dim_order))): continue - # We expect the default dim order for all tensor-like inputs i.e. inputs, buffers, and params - t = node.meta.get("val", None) - if t is not None and getattr(t, "dim_order", None) is not None: - default_dim_order = tuple(range(t.dim())) - if t.dim_order() != default_dim_order: + # Memory dim j is logical dim dim_order[j]. + memory_id = len(xnn_graph.xvalues) + memory = replace( + logical, dims=[logical.dims[d] for d in dim_order], id_out=memory_id + ) + if quantized: + # Activations are quantized per tensor, so the transpose keeps the + # quant params as they are. + if not isinstance(union.quant_params, PerTensorQuant): raise RuntimeError( - f"XNNPACK backend only supports contiguous memory format for inputs." - f"Expecting dim_order: {default_dim_order}, but got {node.meta['val'].dim_order()} for a placeholder node {node}." + f"XNNPACK external {logical.external_id} with dim order " + f"{dim_order} must be quantized per tensor" ) + memory = XNNQuantizedTensorValue( + tensor_value=memory, quant_params=union.quant_params + ) + xnn_graph.xvalues.append(XValue(xvalue_union=memory)) + logical.external_id = XNN_INVALID_VALUE_ID + logical.flags = 0 + + if is_input: + perm = [dim_order.index(i) for i in range(len(dim_order))] + input_id, output_id, nodes = memory_id, logical.id_out, prologue + else: + perm = list(dim_order) + input_id, output_id, nodes = logical.id_out, memory_id, epilogue + transpose = XNNStaticTranspose( + num_dims=len(dim_order), + perm=perm, + input_id=input_id, + output_id=output_id, + flags=0, + ) + nodes.append(XNode(xnode_union=transpose, debug_handle=DEFAULT_DEBUG_HANDLE)) + xnn_graph.xnodes = prologue + xnn_graph.xnodes + epilogue @final @@ -140,11 +224,16 @@ def preprocess( passes.append(ConvertToLinearPass) passes = passes if len(passes) > 0 else None + external_dim_orders = get_external_dim_orders(ep) # XNNPACK Delegate Specific Passes ep = XNNPACKPassManager(ep, passes=passes).transform() graph_module = ep.graph_module node_to_external_map = generate_node_to_external_map(ep, graph_module) + assert len(external_dim_orders) == len(node_to_external_map), ( + f"{len(external_dim_orders)} externals before the XNNPACK passes, " + f"{len(node_to_external_map)} after" + ) # TODO retrace the graph module to lift the new params may have # been added to the graph in passes @@ -185,6 +274,8 @@ def preprocess( continue else: raise RuntimeError(f"{node.op} is not supported in XNNPACK") + + define_externals_in_memory_order(xnnpack_graph, external_dim_orders) return PreprocessResult( processed_bytes=serialize_xnnpack_binary( xnnpack_graph, constant_data_bytes From 186ca414ccd10d00af9a1efee3051b4a3dc9f8a8 Mon Sep 17 00:00:00 2001 From: Muhammad Usman Mateen <74776275+usmanmateen@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:11:58 +0000 Subject: [PATCH 014/128] Keep int64 precision in portable div trunc mode and fmod (#24170) ### Summary Fixes #24169. The portable `div.out_mode` and `div.Scalar_mode_out` kernels compute the integer quotient with `/` and then call `std::trunc` on it, which converts it to `double` and back. Integer division already truncates, so `std::trunc` now only runs for floating-point types. For int64 values above 2^53, `div(a, 3, rounding_mode="trunc")` gave `1537228672809129216` instead of `1537228672809129302`. `fmod.Tensor_out` and `fmod.Scalar_out` switched every integer compute type to `Double`. Integer types now use `%` through a new `utils::integer_fmod` in `math_util.h`, which returns 0 for a divisor of -1 so the most negative value can't overflow. Float types keep the existing `std::fmod` path, including the vectorized one for the Scalar variant. ### Test plan Added `OpDivOutTest.TruncModeKeepsInt64Precision` (Tensor and Scalar divisor) and `OpFmodTest.Int64KeepsFullPrecision` (Tensor, Scalar, and a divisor of -1 with `INT64_MIN`). The expected values come from eager PyTorch. I built `portable_kernels_test` with CMake (`EXECUTORCH_BUILD_TESTS=ON`, Release) on Linux x86_64 (WSL2) at 5b14a2f. Before this change both new tests fail; after it, the full `portable_kernels_test` run passes (1696 tests). `clang-format` 18.1.3 reports no changes on the changed files. This PR was authored with AI assistance (Claude Code). cc @nil-is-all @larryliu0820 @manuelcandales @JakeStevens --- kernels/portable/cpu/op_div.cpp | 36 +++++--- kernels/portable/cpu/op_fmod.cpp | 57 +++++++----- kernels/portable/cpu/util/math_util.h | 26 ++++++ kernels/test/op_div_test.cpp | 49 +++++++++++ kernels/test/op_fmod_test.cpp | 86 +++++++++++++++++++ .../kernels/portable/op_registration_util.bzl | 1 + 6 files changed, 223 insertions(+), 32 deletions(-) diff --git a/kernels/portable/cpu/op_div.cpp b/kernels/portable/cpu/op_div.cpp index 700cefcd55c..f896e4151fc 100644 --- a/kernels/portable/cpu/op_div.cpp +++ b/kernels/portable/cpu/op_div.cpp @@ -150,7 +150,11 @@ Tensor& div_out_mode( } CTYPE_COMPUTE value = val_a / val_b; if (mode_is_trunc) { - value = std::trunc(value); + // Integer division already truncates, and std::trunc would round + // int64 values through double. + if constexpr (!std::is_integral_v) { + value = std::trunc(value); + } } else { // We established above that the mode is either trunc or floor, so // it must be floor. @@ -245,15 +249,6 @@ Tensor& div_scalar_mode_out( InvalidArgument, out); - // Check for intergral division by zero - ET_KERNEL_CHECK_MSG( - ctx, - !(executorch::runtime::isIntegralType(common_type, true) && - utils::scalar_to(b) == 0), - InvalidArgument, - out, - "Div mode operation encountered integer division by zero"); - // Check Dim Order ET_KERNEL_CHECK( ctx, tensors_have_same_dim_order(a, out), InvalidArgument, out); @@ -270,13 +265,25 @@ Tensor& div_scalar_mode_out( // @lint-ignore CLANGTIDY facebook-hte-CArray ET_DEFINE_OPERATOR_NAME(op_name, "div.Scalar_mode_out"); + bool div_by_zero_error = false; + ET_SWITCH_REAL_TYPES(compute_type, ctx, op_name, CTYPE_COMPUTE, [&]() { + // Like ATen, the scalar takes the compute dtype first, so a scalar such as + // 256 for an int8 tensor becomes 0. const CTYPE_COMPUTE val_b = utils::scalar_to(b); + if constexpr (std::is_integral_v) { + if (val_b == 0) { + div_by_zero_error = true; + return; + } + } utils::apply_unitensor_elementwise_fn( [val_b, mode_is_trunc](const CTYPE_COMPUTE val_a) { CTYPE_COMPUTE value = val_a / val_b; if (mode_is_trunc) { - value = std::trunc(value); + if constexpr (!std::is_integral_v) { + value = std::trunc(value); + } } else { value = utils::floor_divide(val_a, val_b); } @@ -289,6 +296,13 @@ Tensor& div_scalar_mode_out( utils::SupportedTensorDtypes::REALHBF16); }); + ET_KERNEL_CHECK_MSG( + ctx, + !div_by_zero_error, + InvalidArgument, + out, + "Div mode operation encountered integer division by zero"); + return out; } diff --git a/kernels/portable/cpu/op_fmod.cpp b/kernels/portable/cpu/op_fmod.cpp index de0d44fd2f1..57b2bb1e0c1 100644 --- a/kernels/portable/cpu/op_fmod.cpp +++ b/kernels/portable/cpu/op_fmod.cpp @@ -10,6 +10,7 @@ #include #include +#include #include namespace torch { @@ -45,32 +46,26 @@ Tensor& fmod_Tensor_out( // Compute Dtype ScalarType compute_type = utils::get_compute_type(common_type); - if (compute_type != ScalarType::Float) { - compute_type = ScalarType::Double; - } // @lint-ignore CLANGTIDY facebook-hte-CArray ET_DEFINE_OPERATOR_NAME(op_name, "fmod.Tensor_out"); bool div_by_zero_error = false; - ET_SWITCH_FLOAT_TYPES(compute_type, ctx, op_name, CTYPE_COMPUTE, [&]() { + ET_SWITCH_REAL_TYPES(compute_type, ctx, op_name, CTYPE_COMPUTE, [&]() { utils::apply_bitensor_elementwise_fn< CTYPE_COMPUTE, op_name, utils::SupportedTensorDtypes::REALHBF16>( [&div_by_zero_error]( const CTYPE_COMPUTE val_a, const CTYPE_COMPUTE val_b) { - // TODO: rewrite this to be vectorization-capable? - CTYPE_COMPUTE value = 0; if (is_integral_type::value) { if (val_b == 0) { div_by_zero_error = true; - return value; + return static_cast(0); } } - value = std::fmod(val_a, val_b); - return value; + return utils::fmod_override(val_a, val_b); }, ctx, a, @@ -106,15 +101,6 @@ Tensor& fmod_Scalar_out( InvalidArgument, out); - // Check for intergral division by zero - ET_KERNEL_CHECK_MSG( - ctx, - !(executorch::runtime::isIntegralType(common_type, true) && - utils::scalar_to(b) == 0), - InvalidArgument, - out, - "Fmod operation encountered integer division by zero"); - // Check Dim Order ET_KERNEL_CHECK( ctx, tensors_have_same_dim_order(a, out), InvalidArgument, out); @@ -125,13 +111,42 @@ Tensor& fmod_Scalar_out( // Compute Dtype ScalarType compute_type = utils::get_compute_type(common_type); - if (compute_type != ScalarType::Float) { - compute_type = ScalarType::Double; - } // @lint-ignore CLANGTIDY facebook-hte-CArray ET_DEFINE_OPERATOR_NAME(op_name, "fmod.Scalar_out"); + if (executorch::runtime::isIntegralType( + compute_type, /*includeBool=*/false)) { + bool div_by_zero_error = false; + ET_SWITCH_INT_TYPES(compute_type, ctx, op_name, CTYPE_COMPUTE, [&]() { + // Like ATen, the scalar takes the compute dtype first, so a scalar such + // as 256 for an int8 tensor becomes 0. + const CTYPE_COMPUTE val_b = utils::scalar_to(b); + if (val_b == 0) { + div_by_zero_error = true; + return; + } + utils::apply_unitensor_elementwise_fn< + CTYPE_COMPUTE, + op_name, + utils::SupportedTensorDtypes::REALHBF16>( + [val_b](const CTYPE_COMPUTE val_a) { + return utils::fmod_override(val_a, val_b); + }, + ctx, + a, + utils::SupportedTensorDtypes::REALHBBF16, + out); + }); + ET_KERNEL_CHECK_MSG( + ctx, + !div_by_zero_error, + InvalidArgument, + out, + "Fmod operation encountered integer division by zero"); + return out; + } + ET_SWITCH_FLOAT_TYPES(compute_type, ctx, op_name, CTYPE_COMPUTE, [&]() { const CTYPE_COMPUTE val_b = utils::scalar_to(b); utils::apply_unitensor_elementwise_fn< diff --git a/kernels/portable/cpu/util/math_util.h b/kernels/portable/cpu/util/math_util.h index 9499eec671e..41ad2f82898 100644 --- a/kernels/portable/cpu/util/math_util.h +++ b/kernels/portable/cpu/util/math_util.h @@ -230,6 +230,32 @@ CTYPE remainder_override(CTYPE a, CTYPE b) { return rem; } +/** + * std::fmod, with integers computed as integers so that int64 values above + * 2^53 are not rounded through double. Like std::fmod, % keeps the sign of a. + * a % -1 overflows for the most negative signed value, and the result is 0 + * for every a. + */ +template < + typename CTYPE, + typename std::enable_if::value, int>::type = + 0> +CTYPE fmod_override(CTYPE a, CTYPE b) { + return std::fmod(a, b); +} + +template < + typename CTYPE, + typename std::enable_if::value, int>::type = 0> +CTYPE fmod_override(CTYPE a, CTYPE b) { + if constexpr (std::is_signed::value) { + if (b == -1) { + return 0; + } + } + return static_cast(a % b); +} + } // namespace utils } // namespace native } // namespace executor diff --git a/kernels/test/op_div_test.cpp b/kernels/test/op_div_test.cpp index d313e85a338..028757fa790 100644 --- a/kernels/test/op_div_test.cpp +++ b/kernels/test/op_div_test.cpp @@ -562,6 +562,22 @@ TEST_F(OpDivOutTest, DISABLED_DynamicShapeUnbound) { EXPECT_TENSOR_CLOSE(out, expected_result); } +TEST_F(OpDivOutTest, TruncModeKeepsInt64Precision) { + TensorFactory tf; + // Values above 2^53, which a double cannot hold exactly. + Tensor a = tf.make( + {3}, + {(int64_t{1} << 62) + 3, -((int64_t{1} << 62) + 3), 9007199254740993}); + Tensor b = tf.make({3}, {3, 3, 1}); + Tensor out = tf.zeros({3}); + + torch::executor::aten::div_outf(context_, a, b, "trunc", out); + EXPECT_TENSOR_EQ( + out, + tf.make( + {3}, {1537228672809129302, -1537228672809129302, 9007199254740993})); +} + TEST_F(OpDivScalarOutTest, SanityCheckIntScalar) { TensorFactory tf_a; TensorFactory tf_out; @@ -590,6 +606,39 @@ TEST_F(OpDivScalarOutTest, SanityCheckFloatScalar) { EXPECT_TENSOR_EQ(out, tf_out.make(sizes, {0.5, 1.0, 2.0, -4.5})); } +TEST_F(OpDivScalarOutTest, TruncModeKeepsInt64Precision) { + TensorFactory tf; + // Values above 2^53, which a double cannot hold exactly. + Tensor a = tf.make( + {3}, + {(int64_t{1} << 62) + 3, -((int64_t{1} << 62) + 3), 9007199254740993}); + Tensor out = tf.zeros({3}); + + torch::executor::aten::div_outf(context_, a, Scalar(3), "trunc", out); + EXPECT_TENSOR_EQ( + out, + tf.make( + {3}, {1537228672809129302, -1537228672809129302, 3002399751580331})); +} + +TEST_F(OpDivScalarOutTest, ModeIntegerScalarIsCastToTensorDtype) { + TensorFactory tf; + Tensor a = tf.make({3}, {5, -7, 100}); + Tensor out = tf.zeros({3}); + + // Like ATen, the scalar takes the tensor's dtype: 255 is -1 as int8. + torch::executor::aten::div_outf(context_, a, Scalar(255), "trunc", out); + EXPECT_TENSOR_EQ(out, tf.make({3}, {-5, 7, -100})); + + // 256 is 0 as int8. + ET_EXPECT_KERNEL_FAILURE( + context_, + torch::executor::aten::div_outf(context_, a, Scalar(256), "trunc", out)); + ET_EXPECT_KERNEL_FAILURE( + context_, + torch::executor::aten::div_outf(context_, a, Scalar(256), "floor", out)); +} + TEST_F(OpDivScalarOutTest, OptimizedSanityCheck) { TensorFactory tf; diff --git a/kernels/test/op_fmod_test.cpp b/kernels/test/op_fmod_test.cpp index 3227a01a17a..9b1fd8547ee 100644 --- a/kernels/test/op_fmod_test.cpp +++ b/kernels/test/op_fmod_test.cpp @@ -13,6 +13,7 @@ #include #include +#include using namespace ::testing; using executorch::aten::Scalar; @@ -31,6 +32,19 @@ class OpFmodTest : public OperatorTest { op_fmod_scalar_out(const Tensor& self, const Scalar& other, Tensor& out) { return torch::executor::aten::fmod_outf(context_, self, other, out); } + + template + void test_integer_fmod() { + TensorFactory tf; + Tensor self = tf.make({4}, {0, 7, 100, 127}); + Tensor out = tf.zeros({4}); + + op_fmod_tensor_out(self, tf.make({4}, {3, 3, 7, 10}), out); + EXPECT_TENSOR_EQ(out, tf.make({4}, {0, 1, 2, 7})); + + op_fmod_scalar_out(self, 3, out); + EXPECT_TENSOR_EQ(out, tf.make({4}, {0, 1, 1, 1})); + } }; TEST_F(OpFmodTest, SmokeTest) { @@ -46,6 +60,78 @@ TEST_F(OpFmodTest, SmokeTest) { EXPECT_TENSOR_CLOSE(out, out_expected); } +TEST_F(OpFmodTest, Int64KeepsFullPrecision) { + TensorFactory tf; + // Values above 2^53, which a double cannot hold exactly. + Tensor self = tf.make( + {4}, + {(int64_t{1} << 62) + 3, + -((int64_t{1} << 62) + 3), + 9007199254740993, + std::numeric_limits::min()}); + Tensor out = tf.zeros({4}); + + op_fmod_tensor_out(self, tf.make({4}, {10, 10, 2, -1}), out); + EXPECT_TENSOR_EQ(out, tf.make({4}, {7, -7, 1, 0})); + + op_fmod_scalar_out(self, 10, out); + EXPECT_TENSOR_EQ(out, tf.make({4}, {7, -7, 3, -8})); + + op_fmod_scalar_out(self, -1, out); + EXPECT_TENSOR_EQ(out, tf.zeros({4})); +} + +TEST_F(OpFmodTest, AllIntegerDtypes) { +#define TEST_ENTRY(ctype, dtype) test_integer_fmod(); + ET_FORALL_INT_TYPES(TEST_ENTRY); +#undef TEST_ENTRY +} + +TEST_F(OpFmodTest, NegativeIntegers) { + TensorFactory tf; + Tensor self = tf.make({4}, {-7, 7, -7, -128}); + Tensor out = tf.zeros({4}); + + op_fmod_tensor_out(self, tf.make({4}, {3, -3, -3, -1}), out); + EXPECT_TENSOR_EQ(out, tf.make({4}, {-1, 1, -1, 0})); +} + +TEST_F(OpFmodTest, IntegerDivisionByZeroDies) { + TensorFactory tf; + Tensor self = tf.make({2}, {5, 6}); + Tensor out = tf.zeros({2}); + + ET_EXPECT_KERNEL_FAILURE( + context_, op_fmod_tensor_out(self, tf.make({2}, {2, 0}), out)); + ET_EXPECT_KERNEL_FAILURE(context_, op_fmod_scalar_out(self, 0, out)); +} + +TEST_F(OpFmodTest, IntegerScalarIsCastToTensorDtype) { + TensorFactory tf; + Tensor self = tf.make({3}, {5, -7, 100}); + Tensor out = tf.zeros({3}); + + // Like ATen, the scalar takes the tensor's dtype: 255 is -1 and 257 is 1 as + // int8. + op_fmod_scalar_out(self, 255, out); + EXPECT_TENSOR_EQ(out, tf.zeros({3})); + op_fmod_scalar_out(self, 257, out); + EXPECT_TENSOR_EQ(out, tf.zeros({3})); + + // 256 is 0 as int8, and 2^32 is 0 as int32. + ET_EXPECT_KERNEL_FAILURE(context_, op_fmod_scalar_out(self, 256, out)); + TensorFactory tf_byte; + Tensor out_byte = tf_byte.zeros({2}); + ET_EXPECT_KERNEL_FAILURE( + context_, op_fmod_scalar_out(tf_byte.make({2}, {5, 200}), 256, out_byte)); + TensorFactory tf_int; + Tensor out_int = tf_int.zeros({2}); + ET_EXPECT_KERNEL_FAILURE( + context_, + op_fmod_scalar_out( + tf_int.make({2}, {5, 6}), Scalar(int64_t{1} << 32), out_int)); +} + TEST_F(OpFmodTest, ScalarSmokeTest) { TensorFactory tfFloat; std::vector a(18); diff --git a/shim_et/xplat/executorch/kernels/portable/op_registration_util.bzl b/shim_et/xplat/executorch/kernels/portable/op_registration_util.bzl index 1eee6f13a15..4ab7f461770 100644 --- a/shim_et/xplat/executorch/kernels/portable/op_registration_util.bzl +++ b/shim_et/xplat/executorch/kernels/portable/op_registration_util.bzl @@ -635,6 +635,7 @@ ATEN_OPS = ( "//executorch/kernels/portable/cpu/util:broadcast_util", "//executorch/kernels/portable/cpu/util:dtype_util", "//executorch/kernels/portable/cpu/util:elementwise_util", + "//executorch/kernels/portable/cpu/util:math_util", ], ), op_target( From ee4b85b4e811c302e0fb1175de4f8b2a189c6055 Mon Sep 17 00:00:00 2001 From: Jacob Szwejbka Date: Tue, 3 Nov 2026 09:36:46 -0800 Subject: [PATCH 015/128] Fix NXP MCUXpresso CIFAR link failure (#24510) Main-branch counterpart of release/1.6 fix #24509. Fixes the NXP MCUXpresso failure seen on #24490; main has the same incomplete CMake target. The [failed job](https://github.com/pytorch/executorch/actions/runs/41601038988/job/125799646998) reaches the final CIFAR firmware link, then fails with `undefined reference to SYS_INFO_PrintAll`. The SDK example calls this helper, but our standalone CMake target omits `examples/eiq_examples/common/sys_info/sys_info.c`. Add that source to the firmware target. The helper also needs `MCUXSDK_VERSION_FULL_STR`, which the SDK's normal build supplies through a generated header. Our standalone target now uses the SDK's `mcux_version.cmake` parser and sets the equivalent compile definition from the checked-out SDK version. ## Verification A focused host CMake build reused the actual example target's source list and include paths, the real SDK helper/header, and the SDK version parser. The board/runtime sources were replaced with empty fixtures and the application with a small C++ caller. The original source list reproduced the unresolved `SYS_INFO_PrintAll` link error. Adding only the helper reproduced the missing `MCUXSDK_VERSION_FULL_STR` compile error. With both changes, the executable linked and printed `MCUX SDK version: 2026.12.00-pvw1`. `git diff --check` passed. The full ARM firmware build and execution on hardware were not run locally; the NXP CI job must validate the complete build. Only the example's CMakeLists.txt changes (eight added lines). The resulting file is byte-identical to the verified release fix in #24509. Land this on main; #24509 is the corresponding release/1.6 pick. Prepared with OpenAI Codex. --- .../imxrt700/executorch_cifarnet/CMakeLists.txt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/examples/nxp/mcuxpresso/imxrt700/executorch_cifarnet/CMakeLists.txt b/examples/nxp/mcuxpresso/imxrt700/executorch_cifarnet/CMakeLists.txt index 0b0dd3f8849..491ed964c1f 100644 --- a/examples/nxp/mcuxpresso/imxrt700/executorch_cifarnet/CMakeLists.txt +++ b/examples/nxp/mcuxpresso/imxrt700/executorch_cifarnet/CMakeLists.txt @@ -58,6 +58,7 @@ add_executable( ${SdkRootDirPath}/examples/eiq_examples/executorch_cifarnet/main.cpp ${SdkRootDirPath}/examples/eiq_examples/executorch_cifarnet/RegisterKernels.cpp ${SdkRootDirPath}/examples/eiq_examples/common/timer.c + ${SdkRootDirPath}/examples/eiq_examples/common/sys_info/sys_info.c ${SdkRootDirPath}/devices/RT/RT700/MIMXRT798S/startup_MIMXRT798S_cm33_core0.c ${EXECUTORCH_ROOT_DIR}/backends/nxp/runtime/NeutronBackend.cpp ${SdkRootDirPath}/middleware/tfm/tf-m/platform/ext/common/syscalls_stub.c @@ -83,6 +84,13 @@ add_executable( ${SdkRootDirPath}/components/debug_console_lite/fsl_debug_console.c ) +# sys_info.c needs the version macro supplied by the SDK's normal build system. +include(${SdkRootDirPath}/cmake/extension/mcux_version.cmake) +target_compile_definitions( + ${MCUX_SDK_PROJECT_NAME} + PRIVATE "MCUXSDK_VERSION_FULL_STR=\"${MCUXSDK_VERSION_FULL}\"" +) + target_include_directories( ${MCUX_SDK_PROJECT_NAME} PRIVATE From 24ad53effe9ace90419ca13b827fa168bf13a5ae Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Tue, 3 Nov 2026 12:01:43 -0800 Subject: [PATCH 016/128] Fix export of zero-stride constants and inputs, and of constants that delegates share (#24404) ### What is wrong **Zero strides.** ExecuTorch cannot store a zero stride, and `contiguous()` keeps one on a size-1 dimension: ```python t = torch.tensor(2.0).expand(1) t.stride() # (0,) t.is_contiguous() # True t.contiguous().stride() # still (0,) ``` So export fails with `0 in strides is not supported for ExecuTorch.` when `constant_prop_pass` folds `self.scale.expand(1)`, a parameter, buffer or constant method holds such a tensor, or an example input is expanded. **Strides of size-1 dimensions.** PyTorch also ignores the stride of a size-1 dimension when it checks contiguity, but ExecuTorch derives the dim order (the order of dimensions in memory) from every stride: ```python q = torch.randn(1, 8, 4).transpose(0, 1) q.stride() # (4, 32, 1) q.is_contiguous() # True, yet the program stores dim order (1, 0, 2) ``` An op that reads such a constant fails at run time: portable `bmm` with `3 input tensors have different dim orders`, XNNPACK with `Propagating input shapes failed`. This happens for a parameter or buffer like `q`, and when `constant_prop_pass` folds `self.queries.transpose(0, 1) * 0.5`. **Constants that ops in different delegates read.** `duplicate_constant_node` gives each such op its own copy. - An op that read it from a list, such as `cat`, kept the original, and lowering failed. - `tag_mutated_buffer` gave a mutated buffer to a delegate even when some readers or writers stayed outside. A reader outside got a stale copy, or lowering failed (new value computed outside, read outside, or also returned). ### What this change does - `without_zero_strides()` in `exir/tensor.py` copies a tensor with a zero stride, keeping channels-last. `constant_prop_pass`, `convert_constant_dim_order_pass` (run by `to_edge`) and `TensorSpec.from_tensor(const=True)` use it. That pass also removes one from method inputs, so an input with a zero stride on a dimension larger than 1 must be passed contiguous. - `convert_constant_dim_order_pass` also checks the dim order that a constant's strides give. If it is neither the default nor channels-last, the pass copies the constant into the layout PyTorch reports. Constants whose strides already give one of those orders do not change, so a channels-last weight with a size-1 channel stays channels-last. - `duplicate_constant_node` copies the constant for every op that reads it, including from a list, and the graph output keeps the original. It raises instead of copying a mutated buffer. - `tag_mutated_buffer` gives a mutated buffer to a delegate only when these hold: - every op that reads it (old or new value) or writes it is in that delegate; - its new value is not returned; - some op reads it, unless the partitioner already tagged it for its writer. Otherwise it stays in the owning program and loses any tag, with a warning. **Behavior change.** A mutated buffer used by ops in different partitions, which used to give stale reads, now stays in the owning program and is passed to each delegate: correct, but copied on every call. A buffer whose new value is a graph input, as in `self.state.copy_(x)`, is no longer tagged, so an existing test in `test_partitioner.py` now expects no tag. With the reader delegated, that model failed to lower on main, and now it lowers and runs. When `constant_prop_pass` runs before `to_edge` and folds a zero-stride constant that PyTorch calls channels-last but not contiguous, the constant now stays channels-last instead of becoming contiguous. Like any other channels-last constant, it then fails at run time (`different dim orders`) in a portable op that also reads a contiguous tensor. ### Testing - New tests under `exir/emit`, `exir/tests` and `exir/backend/test` each fail without their fix. - Existing tests in those folders pass as before. - MobileNetV2 and a small Conv2d model, default and channels-last, give the same `.pte` bytes or the same export error as before, with portable, XNNPACK, and XNNPACK after `constant_prop_pass`. Tested on Linux with torch 2.14.0 (CPU). --- .../duplicate_constant_node_pass.py | 45 ++--- exir/backend/test/BUCK | 3 + exir/backend/test/test_partitioner.py | 42 +++-- exir/backend/test/test_passes.py | 23 +++ exir/backend/test/test_utils.py | 171 +++++++++++++++++- exir/backend/utils.py | 76 +++++--- exir/emit/test/BUCK | 1 + exir/emit/test/test_emit.py | 106 ++++++++++- exir/passes/BUCK | 2 + exir/passes/constant_prop_pass.py | 13 +- .../passes/convert_constant_dim_order_pass.py | 69 +++++-- exir/program/_program.py | 8 +- exir/tensor.py | 21 ++- exir/tests/test_tensor.py | 18 ++ 14 files changed, 506 insertions(+), 92 deletions(-) diff --git a/exir/backend/canonical_partitioners/duplicate_constant_node_pass.py b/exir/backend/canonical_partitioners/duplicate_constant_node_pass.py index 07c3eb5e57a..478c2f35eea 100644 --- a/exir/backend/canonical_partitioners/duplicate_constant_node_pass.py +++ b/exir/backend/canonical_partitioners/duplicate_constant_node_pass.py @@ -40,6 +40,7 @@ def duplicate_constant_node( """ A pass to duplicate the attributes/constants node (the candidate_node) in the graph. Mostly used for duplicating light-weight data. If the data is too large, try tag it with "no_copy" to prevent high memory usage and make it as part of the output. + A mutated buffer is never copied, because a copy would not see its updates: this raises instead. Args: exported_program: the exported program to be modified. If constants nodes are copied, they will be added as new @@ -76,9 +77,23 @@ def duplicate_constant_node( raise RuntimeError( f"placeholder node for non-params, non-buffer, and non-tensor constants should not be tagged: {node} " ) - users = list(node.users.keys()) - for ith in range(1, len(node.users)): - copy_constant_or_attribute_fqn = node.name + f"_copy_{ith - 1}" + if ( + old_signature.inputs_to_buffers.get(node.name) + in old_signature.buffers_to_mutate.values() + ): + raise RuntimeError( + f"mutated buffer ({node}) cannot be copied, because a copy " + "would not see its updates. Tag it only when every node " + "that reads or writes it has the same tag, as " + "tag_mutated_buffer does, or leave it untagged." + ) + # The graph output keeps the original, which the output specs + # name, so then every other user reads a copy. + users = [user for user in node.users if user.op != "output"] + if len(users) == len(node.users): + users = users[1:] + for ith, user in enumerate(users): + copy_constant_or_attribute_fqn = node.name + f"_copy_{ith}" with exported_program.graph.inserting_before( constant_or_attribute_node ): @@ -96,26 +111,12 @@ def duplicate_constant_node( copied_constant_or_attribute_node.meta["val"] = ( constant_or_attribute_node.meta["val"] ) - new_args = tuple( - [ - ( - arg - if arg != constant_or_attribute_node - else copied_constant_or_attribute_node - ) - for arg in users[ith].args - ] + # This also reaches a constant that an op such as cat or + # index_put reads from a list. + user.replace_input_with( + constant_or_attribute_node, + copied_constant_or_attribute_node, ) - new_kwargs = { - key: ( - value - if value != constant_or_attribute_node - else copied_constant_or_attribute_node - ) - for key, value in users[ith].kwargs.items() - } - users[ith].args = new_args - users[ith].kwargs = new_kwargs # The copy keeps the original target, so it reads the # original tensor and needs no data of its own. new_input_specs.append( diff --git a/exir/backend/test/BUCK b/exir/backend/test/BUCK index 99913f65989..99a88f3b7fe 100644 --- a/exir/backend/test/BUCK +++ b/exir/backend/test/BUCK @@ -372,12 +372,15 @@ fbcode_target(_kind = runtime.python_test, "test_utils.py", ], deps = [ + ":backend_with_compiler_demo", ":op_partitioner_demo", "//caffe2:torch", "//executorch/exir:lib", "//executorch/exir/backend:backend_api", + "//executorch/exir/backend:compile_spec_schema", "//executorch/exir/backend:partitioner", "//executorch/exir/backend:utils", + "//executorch/exir/backend/test/demos/rpc:executor_backend_preprocess", "//executorch/exir/dialects:lib", "//executorch/extension/pybindings:portable_lib", # @manual "//executorch/runtime/executor/test:test_backend_compiler_lib", diff --git a/exir/backend/test/test_partitioner.py b/exir/backend/test/test_partitioner.py index b10cdcd436b..1efdba20c7a 100644 --- a/exir/backend/test/test_partitioner.py +++ b/exir/backend/test/test_partitioner.py @@ -658,32 +658,42 @@ def forward(self, x): def test_tag_mutated_buffer_detects_indirect_buffer_mutation(self) -> None: class MutableStateModule(torch.nn.Module): - def __init__(self): + def __init__(self, compute_new_value: bool): super().__init__() + self.compute_new_value = compute_new_value self.register_buffer("state", torch.zeros(1)) def forward(self, x): result = x + self.state - self.state.copy_(x) + self.state.copy_(x + 1 if self.compute_new_value else x) return result - edge = exir.to_edge( - torch.export.export(MutableStateModule(), (torch.ones(1),), strict=True) - ) - exported_program = edge.exported_program() - state_node = next( - node - for node in exported_program.graph.nodes - if exported_program.graph_signature.inputs_to_buffers.get(node.name) - == "state" - ) + # A new value that is a graph input is never in a partition, so the + # buffer then stays in the owning program. delegate_tag = "test_partition" - for user in state_node.users: - user.meta["delegation_tag"] = delegate_tag + for compute_new_value, expected_tag in ((True, delegate_tag), (False, None)): + with self.subTest(compute_new_value=compute_new_value): + edge = exir.to_edge( + torch.export.export( + MutableStateModule(compute_new_value), + (torch.ones(1),), + strict=True, + ) + ) + exported_program = edge.exported_program() + state_node = next( + node + for node in exported_program.graph.nodes + if exported_program.graph_signature.inputs_to_buffers.get(node.name) + == "state" + ) + for node in exported_program.graph.nodes: + if node.op == "call_function": + node.meta["delegation_tag"] = delegate_tag - tag_mutated_buffer(exported_program) + tag_mutated_buffer(exported_program) - self.assertEqual(state_node.meta.get("delegation_tag"), delegate_tag) + self.assertEqual(state_node.meta.get("delegation_tag"), expected_tag) def test_buffer_mutation1(self): class TestModule(torch.nn.Module): diff --git a/exir/backend/test/test_passes.py b/exir/backend/test/test_passes.py index 815a5b5bbed..697f8c78d74 100644 --- a/exir/backend/test/test_passes.py +++ b/exir/backend/test/test_passes.py @@ -86,3 +86,26 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: self.assertEqual(len(copied_nodes), 1) self.assertNotEqual(user.kwargs["other"], buffer_node) + + def test_duplicate_constant_node_refuses_a_mutated_buffer(self) -> None: + class UpdateConst(torch.nn.Module): + def __init__(self): + super().__init__() + self.register_buffer("const", torch.zeros(2)) + + def forward(self, x): + y = x * self.const + self.const.add_(1) + return y + + program = exir.to_edge( + export(UpdateConst(), (torch.ones(2),), strict=True) + ).exported_program() + name = next( + node.name + for node in program.graph.nodes + if node.op == "placeholder" and is_buffer(program, node) + ) + + with self.assertRaisesRegex(RuntimeError, "would not see its updates"): + duplicate_constant_node(program, name) diff --git a/exir/backend/test/test_utils.py b/exir/backend/test/test_utils.py index ff11444930a..24b72e9a1e4 100644 --- a/exir/backend/test/test_utils.py +++ b/exir/backend/test/test_utils.py @@ -10,7 +10,18 @@ from executorch import exir from executorch.exir import to_edge from executorch.exir.backend.backend_api import to_backend -from executorch.exir.backend.partitioner import Partitioner, PartitionResult +from executorch.exir.backend.compile_spec_schema import CompileSpec +from executorch.exir.backend.partitioner import ( + DelegationSpec, + Partitioner, + PartitionResult, +) +from executorch.exir.backend.test.backend_with_compiler_demo import ( + BackendWithCompilerDemo, +) +from executorch.exir.backend.test.demos.rpc.executor_backend_preprocess import ( + ExecutorBackend, +) from executorch.exir.backend.test.op_partitioner_demo import AddMulPartitionerDemo from executorch.exir.backend.utils import ( format_delegated_graph, @@ -18,9 +29,15 @@ get_delegates, get_non_lowered_nodes, is_identical_graph, + tag_constant_data, + tag_mutated_buffer, ) from executorch.exir.dialects._ops import bind_pattern_to_op, ops as exir_ops +from executorch.extension.pybindings.portable_lib import ( # @manual + _get_registered_backend_names, + _load_for_executorch_from_buffer, +) from torch.export import export, ExportedProgram from torch.fx import symbolic_trace from torch.fx.passes.utils.matcher_utils import SubgraphMatcher @@ -390,3 +407,155 @@ def forward(self, a, x, b): payload_names, f"Delegate names mismatch: {delegate_names} vs {payload_names}", ) + + def test_to_backend_keeps_a_mutated_buffer_out_of_a_partial_delegate( # noqa: C901 + self, + ): + class UpdateState(torch.nn.Module): + def __init__(self, read: bool, returned: bool, copy_input: bool): + super().__init__() + self.read = read + self.returned = returned + self.copy_input = copy_input + self.register_buffer("state", torch.zeros(2)) + + def forward(self, x): + if self.read: + out = torch.sin(self.state) + (x + self.state) + else: + out = torch.sin(x) + self.state.copy_(x if self.copy_input else x + x) + if self.returned: + return out, self.state + # In read mode, the last add reads the new value. + return out + self.state if self.read else out + + class TagNodes(Partitioner): + def __init__(self, tags): + super().__init__() + self.tags = tags + + def partition(self, program: ExportedProgram) -> PartitionResult: + for node in program.graph.nodes: + if node.name in self.tags: + node.meta["delegation_tag"] = self.tags[node.name] + tag_mutated_buffer(program) + spec = DelegationSpec( + BackendWithCompilerDemo.__name__, + [CompileSpec("max_value", bytes([4]))], + ) + return PartitionResult( + tagged_exported_program=program, + partition_tags={tag: spec for tag in self.tags.values()}, + ) + + # The delegate owns the buffer only when it reads and writes all of + # it. Otherwise the owning program keeps the one buffer: a copy in the + # delegate would never see the updates. + sin, add, writer = "aten_sin_default", "aten_add_tensor", "aten_add_tensor_2" + # Also the add that makes "out", and the add that reads the new value. + whole = {sin, add, "aten_add_tensor_1", writer, "aten_add_tensor_3"} + has_demo_backend = ( + BackendWithCompilerDemo.__name__ in _get_registered_backend_names() + ) + for read, returned, copy_input, tags, owned_by_program in ( + (True, False, False, dict.fromkeys(whole, "tag0"), False), + (True, False, False, {sin: "tag0", add: "tag0", writer: "tag0"}, True), + (True, False, False, {sin: "tag0", add: "tag0"}, True), + (True, False, False, {sin: "tag0"}, True), + (True, False, False, {sin: "tag0", add: "tag1"}, True), + # Lowering cannot give a delegate a buffer whose new value is returned. + (True, True, False, {sin: "tag0", add: "tag0", writer: "tag0"}, True), + # Without readers, "add" is the writer. Some partitioners tag such a + # buffer themselves. + (False, False, False, {add: "tag0"}, True), + (False, False, False, {add: "tag0", "b_state": "tag0"}, False), + (False, True, False, {add: "tag0", "b_state": "tag0"}, True), + # The new value is the graph input, which no partition holds. + (True, False, True, dict.fromkeys(whole, "tag0"), True), + ): + with self.subTest( + read=read, returned=returned, copy_input=copy_input, tags=tags + ): + x = torch.full((2,), 0.25) + lowered = to_edge( + export(UpdateState(read, returned, copy_input), (x,), strict=True) + ).to_backend(TagNodes(tags)) + self.assertEqual( + [ + spec.arg.name + for spec in lowered.exported_program().graph_signature.input_specs + if spec.target == "state" + ], + ["b_state"] if owned_by_program else [], + ) + if copy_input: + # Every op is delegated, so only the new value keeps the + # buffer out. + self.assertEqual( + get_non_lowered_nodes(lowered.exported_program().graph), [] + ) + if tags == {sin: "tag0"} and has_demo_backend: + eager = UpdateState(read, returned, copy_input) + module = _load_for_executorch_from_buffer( + lowered.to_executorch().buffer + ) + # The demo backend approximates sin with two Taylor terms. + for _ in range(2): + torch.testing.assert_close( + module.forward((x,))[0], eager(x), atol=1e-3, rtol=0 + ) + if not has_demo_backend: + self.skipTest("the runtime has no demo backend to run the lowered program") + + def test_to_backend_copies_a_constant_read_by_ops_in_two_places(self): + class CatConst(torch.nn.Module): + def __init__(self, returned: bool): + super().__init__() + self.returned = returned + self.register_buffer("const", torch.ones(2)) + + def forward(self, x): + out = torch.cat([x, self.const]), torch.cat([self.const, x]) + return (*out, self.const) if self.returned else out + + class TagEachCat(Partitioner): + def partition(self, program: ExportedProgram) -> PartitionResult: + tags = {} + for node in program.graph.nodes: + if node.target == exir_ops.edge.aten.cat.default: + tag = f"tag{len(tags)}" + node.meta["delegation_tag"] = tag + tags[tag] = DelegationSpec(ExecutorBackend.__name__, []) + tag_constant_data(program) + return PartitionResult( + tagged_exported_program=program, partition_tags=tags + ) + + # cat reads the constant from a list. Each delegate gets its own copy, + # and the graph output keeps the original, which the output specs name. + for returned, constants in ( + (False, [["b_const"], ["b_const_copy_0"]]), + (True, [["b_const_copy_0"], ["b_const_copy_1"]]), + ): + with self.subTest(returned=returned): + lowered = to_edge( + export(CatConst(returned), (torch.ones(2),), strict=True) + ).to_backend(TagEachCat()) + program = lowered.exported_program() + self.assertEqual( + [ + [ + spec.arg.name + for spec in module.original_module.graph_signature.input_specs + if spec.target == "const" + ] + for _, module in program.graph_module.named_children() + ], + constants, + ) + if returned: + self.assertEqual( + program.graph.output_node().args[0][-1].name, "b_const" + ) + lowered.to_executorch() diff --git a/exir/backend/utils.py b/exir/backend/utils.py index fc8d0ceeb70..96ff686cc2a 100644 --- a/exir/backend/utils.py +++ b/exir/backend/utils.py @@ -23,6 +23,7 @@ from executorch.exir.dialects._ops import ops as exir_ops from executorch.exir.lowered_backend_module import create_submodule_from_nodes from tabulate import tabulate +from torch.export.graph_signature import OutputKind from torch.fx.experimental.symbolic_shapes import has_free_symbols from torch.fx.node import Node from torch.fx.passes.operator_support import OperatorSupportBase @@ -397,37 +398,66 @@ def tag_constant_data(edge_program: ExportedProgram) -> None: def tag_mutated_buffer(edge_program: ExportedProgram) -> None: """ - Util function for partitioners. This function tags the mutated buffer nodes - whose users all belong within the same partition. This should be called after tagging all other nodes. - Any buffer which is used as input to a subgraph, will be tagged with the same tag as that - subgraph. Throw error when buffers is used across different partitions. That is the - underlying data will be owned by multiple delegates. + Util function for partitioners. This function tags each mutated buffer + node whose readers (of the old or the new value) and writers all belong + within the same partition with the tag of that partition. This should be + called after tagging all other nodes. A buffer that is also read or + written outside that partition stays in the owning program and is passed + to the delegates as an input, because a delegate that owned it would + update its own copy, and the other users would not see the updates. A + buffer whose new value is also a user output stays in the owning program + too, and so does a buffer whose old value no node reads, unless the + partitioner already tagged it with its writers' tag. Any tag already on a + buffer that stays is removed, with a warning. """ # Cache signature lookups to avoid rebuilding dicts on every access. sig = edge_program.graph_signature buffers_map = sig.inputs_to_buffers - mutated_buffer_targets = set(sig.buffers_to_mutate.values()) + nodes = {node.name: node for node in edge_program.graph.nodes} + writers = {} + for output_name, buffer_name in sig.buffers_to_mutate.items(): + writers.setdefault(buffer_name, []).append(nodes[output_name]) + user_outputs = { + spec.arg.name + for spec in sig.output_specs + if spec.kind == OutputKind.USER_OUTPUT + } for node in edge_program.graph.nodes: + buffer_name = buffers_map.get(node.name) + if node.op != "placeholder" or buffer_name not in writers: + continue + # An op that reads the new value reads the buffer too. + new_value_readers = [ + user + for writer in writers[buffer_name] + for user in writer.users + if user.op != "output" + ] + user_tags = { + user.meta.get("delegation_tag", None) + for user in [*node.users, *writers[buffer_name], *new_value_readers] + } + # Lowering does not support a delegate that owns a buffer whose new + # value is also a user output. + returned = any(writer.name in user_outputs for writer in writers[buffer_name]) + # A buffer whose old value no node reads goes to a delegate only when + # the partitioner already gave it to the delegate that writes it. if ( - node.op == "placeholder" - and buffers_map.get(node.name) in mutated_buffer_targets + (node.users or node.meta.get("delegation_tag", None) in user_tags) + and len(user_tags) == 1 + and None not in user_tags + and not returned ): - user_tags = set() - for user in node.users: - user_tag = user.meta.get("delegation_tag", None) - if user_tag is not None: - user_tags.add(user_tag) - if len(user_tags) > 1: - logging.info( - f"The data node is used across multiple partitions, including {user_tags}. " - "If the data is too large and it's not preferred to copy, please tag the " - "constant node like node.['no_copy'] = True and they won't be copied." - ) - # Pick a deterministic consumer tag so a buffer shared across - # partitions is assigned reproducibly across runs. - if len(user_tags) > 0: - node.meta["delegation_tag"] = min(user_tags) + node.meta["delegation_tag"] = user_tags.pop() + continue + had_tag = node.meta.pop("delegation_tag", None) is not None + if had_tag or (node.users and user_tags != {None}): + logging.warning( + f"The mutated buffer {node.name} stays in the owning program: " + "it is read or written outside one partition, no node reads it, " + "or its new value is also returned." + ) def is_shape_dynamic(node: torch.fx.Node) -> bool: diff --git a/exir/emit/test/BUCK b/exir/emit/test/BUCK index 06f21991cdb..d0440dbac46 100644 --- a/exir/emit/test/BUCK +++ b/exir/emit/test/BUCK @@ -36,6 +36,7 @@ fbcode_target(_kind = runtime.python_test, "//executorch/exir/emit:lib", "//executorch/exir/passes:const_prop_pass", "//executorch/exir/passes:constant_prop_pass", + "//executorch/exir/passes:convert_constant_dim_order_pass", "//executorch/exir/passes:init_mutable_pass", "//executorch/exir/passes:propagate_device_pass", "//executorch/exir/tests:lib", diff --git a/exir/emit/test/test_emit.py b/exir/emit/test/test_emit.py index 8e465561b50..6ca6aee763c 100644 --- a/exir/emit/test/test_emit.py +++ b/exir/emit/test/test_emit.py @@ -32,7 +32,7 @@ from executorch.exir.dialects._ops import ops as exir_ops from executorch.exir.emit import emit_program # noqa from executorch.exir.error import InternalError -from executorch.exir.passes import MemoryPlanningPass +from executorch.exir.passes import convert_constant_dim_order_pass, MemoryPlanningPass from executorch.exir.passes.constant_prop_pass import constant_prop_pass from executorch.exir.passes.init_mutable_pass import InitializedMutableBufferPass from executorch.exir.passes.sym_shape_eval_pass import ConstraintBasedSymShapeEvalPass @@ -2665,6 +2665,110 @@ def forward(self): expected_storage.append(j * 16 + i) self.assertEqual([int(v) for v in storage_values], expected_storage) + def test_constant_prop_pass_folds_without_a_zero_stride(self) -> None: + class ScaleByBuffer(nn.Module): + def __init__(self): + super().__init__() + self.register_buffer("scale", torch.tensor(2.0)) + + def forward(self, x): + # expand(1) of a 0-d tensor has stride (0,), yet PyTorch calls + # it contiguous. + return x * self.scale.expand(1) + + exported = constant_prop_pass( + export(ScaleByBuffer(), (torch.randn(4),), strict=True) + ) + self.assertEqual([t.stride() for t in exported.constants.values()], [(1,)]) + + def test_emit_user_constants_with_zero_stride(self) -> None: + def zero_stride(value): + # expand(1) of a 0-d tensor has stride (0,), yet PyTorch calls it + # contiguous. + return torch.tensor(value).expand(1) + + class AddConstants(nn.Module): + def __init__(self): + super().__init__() + self.weight = nn.Parameter(zero_stride(1.0), requires_grad=False) + self.register_buffer("bias", zero_stride(2.0)) + self.register_buffer("offset", zero_stride(3.0), persistent=False) + self.shift = zero_stride(4.0) + # Channels-last, with a zero stride on the size-1 batch dim. + self.register_buffer( + "image", + torch.randn(1, 3, 4, 4).as_strided((1, 3, 4, 4), (0, 1, 12, 3)), + ) + + def forward(self, x): + constants = self.weight + self.bias + self.offset + self.shift + return x + constants + self.image.contiguous() + + model = AddConstants() + x = torch.randn(4) + with self.assertNoLogs(convert_constant_dim_order_pass.logger): + edge = to_edge( + export(model, (x,), strict=True), + constant_methods={"get_scale": zero_stride(5.0)}, + ) + stored_image = edge.exported_program().state_dict["image"] + self.assertEqual(stored_image.stride(), (48, 1, 12, 3)) + program = edge.to_executorch() + + et_module = _load_for_executorch_from_buffer(program.buffer) + self.assertTrue(torch.allclose(model(x), et_module((x,))[0])) + self.assertEqual(et_module.run_method("get_scale", [])[0].tolist(), [5.0]) + + def test_emit_user_input_with_zero_stride(self) -> None: + class Double(nn.Module): + def forward(self, x): + return x * 2 + + # A zero stride on a size-1 dim, as in an expanded example input. PyTorch + # still calls it contiguous. + x = torch.randn(1, 4).as_strided((1, 4), (0, 1)) + program = to_edge(export(Double(), (x,), strict=True)).to_executorch() + + et_module = _load_for_executorch_from_buffer(program.buffer) + self.assertTrue(torch.allclose(x * 2, et_module((x,))[0])) + + def test_emit_constants_with_size_one_dims(self) -> None: + def channels_last(*sizes): + return torch.randn(*sizes).to(memory_format=torch.channels_last) + + class Model(nn.Module): + def __init__(self): + super().__init__() + # PyTorch ignores the stride of a size-1 dim, so it calls + # queries and offset contiguous and frame channels-last, but + # their strides give other dim orders. image already has a + # supported one. + self.queries = nn.Parameter(torch.randn(1, 8, 4).transpose(0, 1)) + self.offset = torch.randn(1, 8, 8).transpose(0, 1) + self.register_buffer("image", channels_last(2, 1, 4, 4)) + self.register_buffer( + "frame", torch.randn(48).as_strided((1, 3, 4, 4), (5, 1, 12, 3)) + ) + + def forward(self, keys, x, y): + return ( + torch.bmm(self.queries, keys) + self.offset, + x + self.image, + y + self.frame, + ) + + model = Model() + inputs = ( + torch.randn(8, 4, 8), + channels_last(2, 1, 4, 4), + channels_last(1, 3, 4, 4), + ) + program = to_edge(export(model, inputs, strict=True)).to_executorch() + + et_module = _load_for_executorch_from_buffer(program.buffer) + for expected, actual in zip(model(*inputs), et_module(inputs)): + self.assertTrue(torch.allclose(expected, actual)) + def test_emit_device_info_propagated_to_serialized_tensor(self) -> None: """Verify that device info from PropagateDevicePass flows through the emitter into ExtraTensorInfo.device_type on serialized tensors.""" diff --git a/exir/passes/BUCK b/exir/passes/BUCK index 9deed4abcea..ef9144279ce 100644 --- a/exir/passes/BUCK +++ b/exir/passes/BUCK @@ -176,6 +176,7 @@ fbcode_target(_kind = runtime.python_library, ], deps = [ "//caffe2:torch", + "//executorch/exir:tensor", "//executorch/exir/dialects:lib", "//executorch/exir/dialects/edge:lib", "//executorch/exir/operator:util", @@ -506,6 +507,7 @@ fbcode_target(_kind = runtime.python_library, ], deps = [ "//caffe2:torch", + "//executorch/exir:tensor", ], ) diff --git a/exir/passes/constant_prop_pass.py b/exir/passes/constant_prop_pass.py index 44b0121268c..769a9a9c2c1 100644 --- a/exir/passes/constant_prop_pass.py +++ b/exir/passes/constant_prop_pass.py @@ -20,6 +20,7 @@ _schema_paired_args, ) from executorch.exir.passes.replace_aten_with_edge_pass import aten_to_edge +from executorch.exir.tensor import without_zero_strides from torch._export.utils import ( get_buffer, get_lifted_tensor_constant, @@ -288,19 +289,13 @@ def get_propagated_const_tensor_dict( # Execute the `node.target` and create a new propagated constant tensor. prop_constant_tensor = node.target(*args_data, **kwargs_data) - # ExecuTorch doesn't support zero strides, so we need to ensure the tensor is contiguous - # if it has any zero strides from broadcasting/expansion operations - if ( - isinstance(prop_constant_tensor, torch.Tensor) - and 0 in prop_constant_tensor.stride() - ): - prop_constant_tensor = prop_constant_tensor.contiguous() - # A folded constant must be a plain tensor that does not require # grad. no_grad is not enough when the op returns a view of a # parameter or the parameter itself. if isinstance(prop_constant_tensor, torch.Tensor): - prop_constant_tensor = prop_constant_tensor.detach() + prop_constant_tensor = without_zero_strides( + prop_constant_tensor + ).detach() const_node_to_tensor[node] = prop_constant_tensor # Only tensors can be lifted into placeholders. A non-tensor value read by diff --git a/exir/passes/convert_constant_dim_order_pass.py b/exir/passes/convert_constant_dim_order_pass.py index 36037961f62..8e1ac204b67 100644 --- a/exir/passes/convert_constant_dim_order_pass.py +++ b/exir/passes/convert_constant_dim_order_pass.py @@ -6,6 +6,7 @@ import logging import torch +from executorch.exir.tensor import dim_order_from_stride, without_zero_strides from torch.export import ExportedProgram from torch.export.graph_signature import InputKind @@ -22,6 +23,29 @@ def _should_transform(tensor: torch.Tensor) -> bool: ) +def _normalize(tensor: torch.Tensor) -> torch.Tensor: + """ + Returns the tensor with default or channels-last dim order and no zero + strides, copying it only if needed. + """ + if _should_transform(tensor): + return tensor.contiguous() + tensor = without_zero_strides(tensor) + ndim = tensor.dim() + dim_order = dim_order_from_stride(tensor.stride(), tuple(tensor.shape)) + if dim_order == tuple(range(ndim)) or ( + ndim in (4, 5) and dim_order == (0, *range(2, ndim), 1) + ): + return tensor + # PyTorch ignores the stride of a size-1 dim when it checks contiguity, so + # sizes (8, 1, 4) with strides (4, 32, 1) are contiguous, yet give dim + # order (1, 0, 2). + memory_format = ( + torch.contiguous_format if tensor.is_contiguous() else torch.channels_last + ) + return tensor.clone(memory_format=memory_format) + + def _update_placeholder_meta( exported_program: ExportedProgram, target: str, @@ -49,8 +73,11 @@ def _update_placeholder_meta( if placeholder_node is None: raise RuntimeError(f"Missing placeholder for {input_spec.arg.name}.") - strides = placeholder_node.meta["val"].stride() - placeholder_node.meta["val"] = placeholder_node.meta["val"].contiguous() + val = placeholder_node.meta["val"] + placeholder_node.meta["val"] = _normalize(val) + # Fixing a stride that PyTorch ignores does not change the layout. + if not _should_transform(val): + return logger.warning( "%s '%s' has strides %s, which are neither contiguous nor channels-last. " "to_edge stores it contiguous, so partitioners and delegates see the " @@ -58,7 +85,7 @@ def _update_placeholder_meta( "where it is created to avoid this change.", kind.name.replace("_", " ").capitalize(), target, - tuple(strides), + tuple(val.stride()), ) @@ -69,8 +96,11 @@ def convert_constant_dim_order_pass( Normalize lifted tensors to default or channels-last dim order. Tensor constants, buffers and parameters with other dim orders or striding - are converted to contiguous tensors. This pass acts in-place on the unlifted - exported program. + are converted to contiguous tensors. Those that PyTorch calls contiguous or + channels-last, but whose strides on size-1 dims give another dim order, are + copied into that layout, and zero strides are removed. User inputs with a + zero stride become contiguous, or stay channels-last; other inputs are not + changed. This pass acts in-place on the unlifted exported program. Args: exported_program: The ExportedProgram to transform. @@ -84,8 +114,11 @@ def convert_constant_dim_order_pass( ) for key, const in exported_program.constants.items(): - if isinstance(const, torch.Tensor) and _should_transform(const): - exported_program.constants[key] = const.contiguous() + if not isinstance(const, torch.Tensor): + continue + normalized = _normalize(const) + if normalized is not const: + exported_program.constants[key] = normalized # Also update the corresponding placeholder node meta value. This doesn't # get automatically updated during retracing as ExportPass uses the placeholder @@ -99,22 +132,28 @@ def convert_constant_dim_order_pass( _update_placeholder_meta(exported_program, key, kind) for key, param in exported_program.named_parameters(): - if _should_transform(param): + normalized = _normalize(param) + if normalized is not param: exported_program.state_dict[key] = torch.nn.Parameter( - param.contiguous(), requires_grad=param.requires_grad + normalized, requires_grad=param.requires_grad ) _update_placeholder_meta(exported_program, key, InputKind.PARAMETER) # Convert buffers. for key, buffer in exported_program.named_buffers(): - if ( - key not in non_persistent_buffer_names - and isinstance(buffer, torch.Tensor) - and _should_transform(buffer) - ): + if key in non_persistent_buffer_names or not isinstance(buffer, torch.Tensor): + continue + normalized = _normalize(buffer) + if normalized is not buffer: # Persistent buffers are stored in the state dict. - exported_program.state_dict[key] = buffer.contiguous() + exported_program.state_dict[key] = normalized _update_placeholder_meta(exported_program, key, InputKind.BUFFER) + # An expanded example input has a zero stride, which ExecuTorch cannot + # store either. Constants are already normalized, so this only changes inputs. + for node in exported_program.graph.nodes: + if node.op == "placeholder" and isinstance(node.meta.get("val"), torch.Tensor): + node.meta["val"] = without_zero_strides(node.meta["val"]) + return exported_program diff --git a/exir/program/_program.py b/exir/program/_program.py index 442a2658fdf..7394e17151b 100644 --- a/exir/program/_program.py +++ b/exir/program/_program.py @@ -826,8 +826,8 @@ def _generate_edge_program( program = remove_unused_parameters_pass(program) if not config._skip_dim_order: - # Normalize constants, buffers and parameters before partitioning, so a - # delegate sees the layout the program stores, and before + # Normalize constants, buffers, parameters and inputs before + # partitioning, so a delegate sees the layout the program stores, and before # MemoryFormatOpsPass rewrites preserve-format copies, which cannot # represent arbitrary dim orders. program = convert_constant_dim_order_pass.convert_constant_dim_order_pass( @@ -884,8 +884,8 @@ def _generate_edge_program( # Normalize tensor constants introduced by the preceding passes. With # _skip_dim_order this is the only call, so it fixes the layout of - # parameters, buffers and constants, but ops computed from them keep the - # old strides. + # parameters, buffers, constants and inputs, but ops computed from them + # keep the old strides. edge_program = convert_constant_dim_order_pass.convert_constant_dim_order_pass( edge_program ) diff --git a/exir/tensor.py b/exir/tensor.py index 50fcd46fa53..40d6dfbb060 100644 --- a/exir/tensor.py +++ b/exir/tensor.py @@ -118,6 +118,25 @@ def __lt__(self, other): return tuple(typing.cast(Tuple[bytes], sorted_dims)) +def without_zero_strides(tensor: torch.Tensor) -> torch.Tensor: + """ + Returns the tensor, or a copy of it if it has a zero stride, which + ExecuTorch cannot store. The copy is channels-last only if PyTorch calls + the tensor channels-last but not contiguous, and contiguous otherwise. + + Tensor.contiguous() is not enough: PyTorch ignores the stride of a size-1 + dim, and every stride of an empty tensor, when it checks contiguity, so it + can return a tensor with a zero stride unchanged. + """ + if 0 not in tensor.stride(): + return tensor + if not tensor.is_contiguous() and tensor.is_contiguous( + memory_format=torch.channels_last + ): + return tensor.clone(memory_format=torch.channels_last) + return tensor.clone(memory_format=torch.contiguous_format) + + def stride_from_dim_order(sizes: List[int], dim_order: List[int]) -> List[int]: """ Converts dim order to stride using sizes @@ -210,7 +229,7 @@ def nbytes(self) -> int: def from_tensor(cls, tensor: torch.Tensor, const: bool = False) -> TensorSpec: if const: # for non-contigous tensors, convert to a contiguous one - tensor = tensor.contiguous() + tensor = without_zero_strides(tensor.contiguous()) # Weights cannot be views during emission or serialization if tensor.nbytes != tensor.untyped_storage().nbytes(): tensor = tensor.clone() diff --git a/exir/tests/test_tensor.py b/exir/tests/test_tensor.py index d6307d716e1..1ce281cb455 100644 --- a/exir/tests/test_tensor.py +++ b/exir/tests/test_tensor.py @@ -24,6 +24,7 @@ scalar_type_enum, stride_from_dim_order, TensorSpec, + without_zero_strides, ) @@ -384,6 +385,23 @@ def test_dim_order_from_stride_with_symbolic_sizes(self) -> None: ) self.assertEqual(shape_env.guards, []) + def test_without_zero_strides(self) -> None: + dense = torch.arange(4.0) + self.assertIs(without_zero_strides(dense), dense) + + for tensor, stride in [ + (torch.tensor(2.0).expand(1), (1,)), + (torch.zeros(3).expand(0, 3), (3, 1)), + ( + torch.randn(1, 3, 4, 4).as_strided((1, 3, 4, 4), (0, 1, 12, 3)), + (48, 1, 12, 3), + ), + ]: + self.assertIn(0, tensor.stride()) + copy = without_zero_strides(tensor) + self.assertEqual(copy.stride(), stride) + self.assertTrue(torch.equal(copy, tensor)) + def test_strides_from_dim_order(self) -> None: sizes = [] dim_order = [] From 3bc52229cbc2697af3c291ee3a99a018158b9d84 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Tue, 3 Nov 2026 12:20:46 -0800 Subject: [PATCH 017/128] Fix wrong dtypes and values in Core ML conversion (#24424) ## What is wrong today coremltools converts some ops differently from PyTorch, so lowering fails or outputs are wrong: - `sqrt`, `exp`, `sin` and similar ops fail to lower on an int or bool tensor, which PyTorch computes in float. So do `floor`, `ceil`, `round`, `relu`, `relu6` and `hardtanh` on an int tensor, though PyTorch keeps their result int. - Copying an int or bool tensor into a float one, as `y[:, i] = v` does, keeps its dtype, so a later float-only op fails to lower. - `clamp_min` and `clamp_max` fail to lower when the tensor and the bound differ in dtype, as in `x.clamp_min(0)` on a float `x`. - Fill ops such as `full` and `zeros_like` get the wrong dtype: `torch.full((1, 16), 0)` is int64 in PyTorch and float in Core ML, so later ops fail or lose precision. `nn.MultiheadAttention` hits this through its bool mask. - `gelu(approximate="tanh")` runs as the exact GELU: coremltools ignores the keyword `torch.export` passes. - `nn.RMSNorm(eps=None)` fails to lower: coremltools requires `eps`. - A Core ML program that returns an input, one value twice, or a same-shape `expand` of another output can silently return one of them wrong. - `y[:, i] = v` with `i = -1` writes to an empty slice: lowering fails for a constant `i`, and for an input `i` Core ML fails at run time or, from iOS 18, silently skips the write. ## What this change does - `compiler/torch_ops.py` overrides the converters of these ops; each override can go once coremltools fixes its bug. Inputs get the dtype PyTorch computes in: float for the ops that need it, int kept for the rounding and clipping ops, the dtype of `y` for a value copied into it, and the result's dtype for `clamp_min` and `clamp_max`. Fills get PyTorch's dtype, `gelu` reads its keyword, `rms_norm` gets PyTorch's default eps, and a negative index next to a slice gets the dim size added. The input fixes come first, since the fill fix sends int fills to them. - `compiler/coreml_preprocess.py` turns each same-shape `tile` output into a copy of its input, and copies an output that repeats an input or another output. ## Testing - The Core ML tests (`test_torch_ops.py`, `test_coreml_partitioner.py`, `test_coreml_recipes.py`) pass on Linux and on macOS, where the new tests also compare the Core ML output with eager PyTorch. - Each new test fails when its fix is removed. --- .../coreml/compiler/coreml_preprocess.py | 44 ++ backends/apple/coreml/compiler/torch_ops.py | 329 ++++++++++ backends/apple/coreml/test/test_torch_ops.py | 586 +++++++++++++++++- 3 files changed, 952 insertions(+), 7 deletions(-) diff --git a/backends/apple/coreml/compiler/coreml_preprocess.py b/backends/apple/coreml/compiler/coreml_preprocess.py index 931d1552606..8d44661f35e 100644 --- a/backends/apple/coreml/compiler/coreml_preprocess.py +++ b/backends/apple/coreml/compiler/coreml_preprocess.py @@ -17,6 +17,9 @@ import coremltools as ct import coremltools.optimize as cto +from coremltools.converters.mil import Builder as mb +from coremltools.converters.mil.mil.passes.graph_pass import AbstractGraphPass +from coremltools.converters.mil.mil.passes.pass_registry import register_pass from executorch.backends.apple.coreml import executorchcoreml from executorch.backends.apple.coreml.compiler.enumerated_shape_utils import ( _get_ct_inputs, @@ -67,6 +70,46 @@ def _gathers_sharing_a_weight(mlmodel: ct.models.MLModel) -> List[str]: return shared +@register_pass(namespace="executorch", override=True, name="copy_reused_outputs") +class _CopyReusedOutputs(AbstractGraphPass): + """ + Gives each output of the program a value of its own. + + coremltools converts ops such as clone into nothing, and keeps a tile that + changes nothing, such as one from a same-shape expand, only when it makes + an output. So a partition can return one of its own inputs, or one value + twice, directly or through such a tile. Core ML then hands back one of + those outputs wrong, such as all zeros or the bytes of another output. + """ + + def apply(self, prog): + for function in prog.functions.values(): + seen = set(function.inputs.values()) + outputs, tiles = [], [] + for output in function.outputs: + value = output + if ( + output.op is not None + and output.op.op_type == "tile" + and output.op.x.sym_type == output.sym_type + ): + tiles.append(output.op) + value = output.op.x + if value is not output or value in seen: + with function: + copy = mb.identity(x=value) + # coremltools wants every op to carry the source program's + # scopes, which set_outputs, unlike a var replacement, skips. + # The copy takes those of the output it replaces. + function._copy_scope_info(output, copy) + value = copy + seen.add(value) + outputs.append(value) + function.set_outputs(outputs) + # A pass list without dead_code_elimination would keep such a tile. + function.remove_ops([op for op in tiles if not op.outputs[0].child_ops]) + + class COMPILE_SPEC_KEYS(Enum): COMPUTE_UNITS = "compute_units" MODEL_TYPE = "model_type" @@ -565,6 +608,7 @@ def _convert_to_mlmodel( ) compute_units = CoreMLBackend.compute_unit_from_compile_specs(compile_specs) pass_pipeline = CoreMLBackend.pass_pipeline_from_compile_specs(compile_specs) + pass_pipeline.append_pass("executorch::copy_reused_outputs") enumerated_shapes = CoreMLBackend.enumerated_shapes_from_compile_specs( compile_specs ) diff --git a/backends/apple/coreml/compiler/torch_ops.py b/backends/apple/coreml/compiler/torch_ops.py index 7f6abe980e1..ed9cf0d5e18 100644 --- a/backends/apple/coreml/compiler/torch_ops.py +++ b/backends/apple/coreml/compiler/torch_ops.py @@ -8,13 +8,19 @@ # coremltools than is used by ExecuTorch. Each op registered here should have a link to a PR in coremltools that adds # the op to the coremltools library. +import copy +import functools + import numpy as np import torch as _torch from coremltools import _logger +from coremltools.converters.mil import Builder as mb from coremltools.converters.mil.frontend import _utils +from coremltools.converters.mil.frontend.torch import ops as _ct_ops from coremltools.converters.mil.frontend.torch.ops import ( _get_inputs, _get_kwinputs, + _make_fill_op, noop, NUM_TO_NUMPY_DTYPE, NUM_TO_TORCH_DTYPE, @@ -25,6 +31,7 @@ register_torch_op, ) from coremltools.converters.mil.mil import types +from coremltools.converters.mil.mil.types.symbolic import is_symbolic from executorch.exir.dim_order_utils import get_memory_format @@ -49,6 +56,155 @@ def split_copy(context, node): split(context, node) +# https://github.com/apple/coremltools/pull/2863 +# coremltools reads approximate only from the positional inputs, but torch.export +# passes it as a keyword, so gelu(approximate="tanh") became the exact gelu. +@register_torch_op(override=True) +def gelu(context, node): + inputs = _get_inputs(context, node, expected=[1, 2]) + if len(inputs) == 2: + approximate = inputs[1].val + else: + # Read the keyword as is: _get_kwinputs would bind "tanh" to a node named tanh. + approximate = (node.kwinputs or {}).get("approximate", ["none"])[0] + mode = {"none": "EXACT", "tanh": "TANH_APPROXIMATION"}[approximate] + context.add(mb.gelu(x=inputs[0], mode=mode, name=node.name)) + + +def _torch_dtype(node): + """The dtype torch gives the output of node, if node carries it, as a node of + an ExportedProgram does.""" + val = (node.meta or {}).get("val") + return val.dtype if isinstance(val, _torch.Tensor) else None + + +def _core_ml_type(node): + """The numpy type Core ML uses for the dtype torch gives the output of node.""" + dtype = _torch_dtype(node) + if dtype == _torch.bool: + return np.bool_ + if dtype == _torch.float16: + return np.float16 + return np.float32 if dtype.is_floating_point else np.int32 + + +def _add_fill(context, node, shape, value): + context.add(_make_fill_op(shape, _core_ml_type(node)(value), node.name)) + + +# coremltools gives these fills the type of their fill value or of their input, +# or makes them float, instead of the dtype torch gives them, so an int or bool +# fill could come out as float. https://github.com/apple/coremltools/pull/2865 +# fixes zeros_like; no upstream issue covers the others yet. For example, +# torch.full((1, 16), 0) is int64 in torch, and coremltools makes it fp32. A +# TorchScript node, which carries no dtype, and a fill value known only at run +# time keep the coremltools converter. +@register_torch_op(override=True) +def full(context, node): + inputs = _get_inputs(context, node, min_expected=2) + if _torch_dtype(node) is None or inputs[1].val is None: + return _ct_ops.full(context, node) + _add_fill(context, node, inputs[0], inputs[1].val) + + +@register_torch_op(override=True) +def new_full(context, node): + inputs = _get_inputs(context, node, min_expected=3) + if _torch_dtype(node) is None or inputs[2].val is None: + return _ct_ops.new_full(context, node) + _add_fill(context, node, inputs[1], inputs[2].val) + + +def _fill_like(context, node, convert): + inputs = _get_inputs(context, node, min_expected=2) + if _torch_dtype(node) is None or inputs[1].val is None: + return convert(context, node) + _add_fill(context, node, mb.shape(x=inputs[0]), inputs[1].val) + + +@register_torch_op(override=True) +def full_like(context, node): + _fill_like(context, node, _ct_ops.full_like) + + +@register_torch_op(override=True) +def fill(context, node): + _fill_like(context, node, _ct_ops.fill) + + +# coremltools converts empty, new_empty and empty_like as zeros, new_zeros and +# zeros_like, so these overrides take their names too. +@register_torch_op(torch_alias=["empty.memory_format"], override=True) +def zeros(context, node): + if _torch_dtype(node) is None: + return _ct_ops.zeros(context, node) + _add_fill(context, node, _get_inputs(context, node, min_expected=1)[0], 0) + + +@register_torch_op(torch_alias=["new_empty"], override=True) +def new_zeros(context, node): + if _torch_dtype(node) is None: + return _ct_ops.new_zeros(context, node) + _add_fill(context, node, _get_inputs(context, node, min_expected=2)[1], 0) + + +@register_torch_op(torch_alias=["empty_like"], override=True) +def zeros_like(context, node): + if _torch_dtype(node) is None: + return _ct_ops.zeros_like(context, node) + x = _get_inputs(context, node, min_expected=1)[0] + _add_fill(context, node, mb.shape(x=x), 0) + + +@register_torch_op(override=True) +def ones_like(context, node): + if _torch_dtype(node) is None: + return _ct_ops.ones_like(context, node) + x = _get_inputs(context, node, min_expected=1)[0] + _add_fill(context, node, mb.shape(x=x), 1) + + +@register_torch_op(override=True) +def scalar_tensor(context, node): + x = _get_inputs(context, node, expected=[1, 5])[0] + if _torch_dtype(node) is None or x.val is None: + return _ct_ops.scalar_tensor(context, node) + context.add(mb.const(val=_core_ml_type(node)(x.val), name=node.name)) + + +# coremltools writes y[:, i] = v to y[:, i:i + 1] without first wrapping a +# negative i, so for i = -1 it writes to an empty slice. No upstream issue +# covers this yet. For example, y[:, torch.tensor([-1])] = v fails to lower +# with "The update tensor should have shape [3, 0]. Got (3, 1)", and from +# iOS 18 Core ML skips the write when i is an input. A TorchScript node, which +# passes its indices as one list, keeps the coremltools converter. +@register_torch_op(override=True) +def index_put(context, node): + names = node.inputs[1] + if not isinstance(names, tuple) or None not in names: + return _ct_ops.index_put(context, node) + x = context[node.inputs[0]] + wrapped = list(names) + for dim, name in enumerate(names): + if name is None or not types.is_int(context[name].dtype): + continue + index = context[name] + size = x.shape[dim] + if is_symbolic(size): + size = _utils.pymil_value_at(mb.shape(x=x), dim) + index = mb.select( + cond=mb.less(x=index, y=0), + a=mb.add(x=index, y=size), + b=index, + name=f"{node.name}_index_{dim}", + ) + context.add(index) + wrapped[dim] = index.name + node = copy.copy(node) + node.inputs = (node.inputs[0], tuple(wrapped), *node.inputs[2:]) + return _ct_ops.index_put(context, node) + + def is_fbcode(): return not hasattr(_torch.version, "git_version") @@ -58,6 +214,7 @@ def is_fbcode(): _empty_dim_order, _to_dim_order_copy, ) + from coremltools.converters.mil.frontend.torch.ops import rms_norm as _rms_norm # This is a temporary hack to register the alias "dim_order_ops._to_dim_order_copy", # which was missed by coremltools @@ -71,6 +228,19 @@ def _to_dim_order_copy_TMP_EXECUTORCH_ALIAS_HACK(context, node): def _empty_dim_order_TMP_EXECUTORCH_ALIAS_HACK(context, node): _empty_dim_order(context, node) + # coremltools 9.0 requires the eps that torch.nn.RMSNorm(eps=None) leaves out. + # Torch then uses the float32 machine epsilon, also for float16 inputs, while + # https://github.com/apple/coremltools/pull/2743 would use 1e-5, so keep this + # override after that change lands. + @register_torch_op(override=True) + def rms_norm(context, node): + inputs = list(node.inputs) + [None] * (4 - len(node.inputs)) + if inputs[3] is None: + inputs[3] = float(np.finfo(np.float32).eps) + node = copy.copy(node) + node.inputs = inputs + _rms_norm(context, node) + else: # TODO: remove this case when fbcode updates to coremltools 9.0 @register_torch_op( @@ -238,3 +408,162 @@ def dequantize_codebook(context, node): except ValueError as e: _raise_with_executorch_hint(e) context.add(output, node.name) + + +# Torch computes these ops in float for an int or bool input, while coremltools +# passes such an input on to Core ML ops that take only float, except for an int +# input of log and log1p. https://github.com/apple/coremltools/pull/2683 and +# https://github.com/apple/coremltools/pull/2735 cast an int input of +# reciprocal, log2 and log10 after coremltools 9.0; no upstream change covers +# the other ops or a bool input yet. For example, lowering x.long().sqrt() fails +# with "expects tensor or scalar of dtype from type domain ['fp16', 'fp32'] but +# got tensor[2,3,int32]". +def _with_float_input(convert): + @functools.wraps(convert) + def converter(context, node): + x = context[node.inputs[0]] + if types.is_int(x.dtype) or types.is_bool(x.dtype): + context.add(mb.cast(x=x, dtype="fp32", name=f"{node.name}_input_fp32")) + node = copy.copy(node) + node.inputs = [f"{node.name}_input_fp32", *node.inputs[1:]] + convert(context, node) + + return converter + + +for _convert in ( + _ct_ops.acos, + _ct_ops.asin, + _ct_ops.atan, + _ct_ops.atanh, + _ct_ops.cos, + _ct_ops.cosh, + _ct_ops.erf, + _ct_ops.exp, + _ct_ops.expm1, + _ct_ops.log, + _ct_ops.log1p, + _ct_ops.log10, + _ct_ops.log2, + _ct_ops.reciprocal, + _ct_ops.rsqrt, + _ct_ops.sigmoid, + _ct_ops.sin, + _ct_ops.sinh, + _ct_ops.sqrt, + _ct_ops.tan, + _ct_ops.tanh, +): + register_torch_op(_with_float_input(_convert), override=True) + + +# For an int input, torch returns floor, ceil and round of it unchanged, and +# computes relu and relu6 in int, while coremltools passes the input on to Core +# ML ops that take only float. No upstream issue covers this yet. For example, +# lowering x.long().floor() fails with "expects tensor or scalar of dtype from +# type domain ['fp16', 'fp32'] but got tensor[2,3,int32]". +def _with_int_input(convert, convert_int): + @functools.wraps(convert) + def converter(context, node): + x = context[node.inputs[0]] + if types.is_int(x.dtype): + context.add(convert_int(x=x, name=node.name)) + else: + convert(context, node) + + return converter + + +for _convert, _convert_int in ( + (_ct_ops.ceil, mb.identity), + (_ct_ops.floor, mb.identity), + (_ct_ops.relu, lambda x, name: mb.maximum(x=x, y=0, name=name)), + (_ct_ops.relu6, lambda x, name: mb.minimum(x=mb.maximum(x=x, y=0), y=6, name=name)), +): + register_torch_op(_with_int_input(_convert, _convert_int), override=True) +# coremltools registers round as _round with the alias round. +register_torch_op( + _with_int_input(_ct_ops._round, mb.identity), torch_alias=["round"], override=True +) + + +# Torch clips the input of hardtanh at its bounds in the dtype of the input, +# while coremltools passes the bounds as they are to clip, which takes only +# float and bounds of the dtype of the input. So nn.ReLU6 on an int tensor, and +# F.relu6(x, inplace=True), which passes int bounds, fail to lower with "the +# named input `alpha` must have the same data type as the named input `x`". +# https://github.com/apple/coremltools/pull/2894 casts the bounds to the dtype of +# a float input; no upstream change covers an int input yet. The dtype comes from +# torch, since coremltools gives some tensors another one, such as int32 to +# torch.arange(4, dtype=torch.float32). A TorchScript node, which carries no +# dtype, keeps the coremltools converter. +@register_torch_op(override=True) +def hardtanh(context, node): + if _torch_dtype(node) is None: + return _ct_ops.hardtanh(context, node) + x, low, high = _get_inputs(context, node, expected=3) + dtype = types.numpy_type_to_builtin_type(_core_ml_type(node)) + if x.dtype != dtype: + x = mb.cast(x=x, dtype=types.builtin_to_string(dtype)) + if types.is_int(x.dtype): + # Torch drops the fraction of each bound for an int input, as int() does. + clipped = mb.maximum(x=x, y=int(low.val)) + context.add(mb.minimum(x=clipped, y=int(high.val), name=node.name)) + else: + dtype = types.nptype_from_builtin(x.dtype) + context.add( + mb.clip(x=x, alpha=dtype(low.val), beta=dtype(high.val), name=node.name) + ) + + +# Torch computes clamp_min and clamp_max in the dtype it promotes the tensor and +# the bound to, while coremltools requires the two to share a dtype. So +# x.clamp_min(0) of a float x, and a float bound on an int tensor, fail to lower +# with an AssertionError. No upstream issue covers this yet. A TorchScript node, +# which carries no dtype, keeps the coremltools converter. +def _with_inputs_in_torch_dtype(op, convert): + @functools.wraps(convert) + def converter(context, node): + if _torch_dtype(node) is None: + return convert(context, node) + dtype = types.numpy_type_to_builtin_type(_core_ml_type(node)) + x, bound = _get_inputs(context, node, expected=2) + if x.dtype != dtype: + x = mb.cast(x=x, dtype=types.builtin_to_string(dtype)) + if bound.dtype != dtype: + bound = mb.cast(x=bound, dtype=types.builtin_to_string(dtype)) + context.add(op(x=x, y=bound, name=node.name)) + + return converter + + +for _op, _convert in ( + (mb.maximum, _ct_ops.clamp_min), + (mb.minimum, _ct_ops.clamp_max), +): + register_torch_op(_with_inputs_in_torch_dtype(_op, _convert), override=True) + + +# Torch copies src into the dtype of self, while coremltools' copy keeps the +# dtype of src. Lowering turns y[:, i] = v into a copy too, so v kept its dtype +# in y. No upstream issue covers this yet. For example, lowering +# F.gelu(x.clone().copy_(x.long())) fails with "expects tensor or scalar of +# dtype from type domain ['fp16', 'fp32'] but got tensor[2,3,int32]". The dtype +# comes from torch where the node carries it, since coremltools gives some +# tensors another one, such as int32 to torch.arange(4, dtype=torch.float32). +def _copy_into_dtype_of_self(context, node): + x, src = context[node.inputs[0]], context[node.inputs[1]] + dtype = x.dtype + if _torch_dtype(node) is not None: + dtype = types.numpy_type_to_builtin_type(_core_ml_type(node)) + if src.dtype != dtype: + name = f"{node.name}_src" + context.add(mb.cast(x=src, dtype=types.builtin_to_string(dtype), name=name)) + node = copy.copy(node) + node.inputs = [node.inputs[0], name, *node.inputs[2:]] + _ct_ops.copy(context, node) + + +register_torch_op( + functools.wraps(_ct_ops.copy)(_copy_into_dtype_of_self), override=True +) diff --git a/backends/apple/coreml/test/test_torch_ops.py b/backends/apple/coreml/test/test_torch_ops.py index 10c3f01a585..39d64a3f01e 100644 --- a/backends/apple/coreml/test/test_torch_ops.py +++ b/backends/apple/coreml/test/test_torch_ops.py @@ -9,10 +9,13 @@ import executorch.exir import torch +from coremltools.converters.mil.mil import types +from coremltools.converters.mil.mil.scope import ScopeSource from executorch.backends.apple.coreml.compiler import CoreMLBackend from executorch.backends.apple.coreml.partition import CoreMLPartitioner from executorch.backends.apple.coreml.test.test_coreml_utils import ( + is_fbcode, IS_VALID_TEST_RUNTIME, ) from executorch.exir.backend.utils import format_delegated_graph @@ -40,17 +43,23 @@ def _get_test_model(self): example_inputs = (torch.LongTensor([0]),) return model, example_inputs - def _compare_outputs(self, executorch_program, eager_program, example_inputs): + def _compare_outputs( + self, executorch_program, eager_program, example_inputs, atol=1e-2, rtol=1e-2 + ): if not IS_VALID_TEST_RUNTIME: return runtime = Runtime.get() program = runtime.load_program(executorch_program.buffer) method = program.load_method("forward") - et_outputs = method.execute(example_inputs)[0] + et_outputs = method.execute(example_inputs) eager_outputs = eager_program(*example_inputs) - self.assertTrue( - torch.allclose(et_outputs, eager_outputs, atol=1e-02, rtol=1e-02) - ) + if isinstance(eager_outputs, torch.Tensor): + eager_outputs = (eager_outputs,) + self.assertEqual(len(et_outputs), len(eager_outputs)) + for et_output, eager_output in zip(et_outputs, eager_outputs): + self.assertTrue( + torch.allclose(et_output, eager_output, atol=atol, rtol=rtol) + ) def test_dequantize_affine_b4w_embedding(self): model, example_inputs = self._get_test_model() @@ -93,6 +102,222 @@ def test_dequantize_affine_b4w_linear(self): et_prog = delegated_program.to_executorch() self._compare_outputs(et_prog, model, example_inputs) + def test_int_input_of_float_ops(self): + """ + Torch computes these ops in float for an int or bool input, and + coremltools passed the input on, so lowering failed. + """ + + class Model(torch.nn.Module): + def forward(self, x): + i = x.to(torch.int64) + j = i + 1 + k = (i > 1).to(torch.int64) + return torch.stack( + [ + i.sqrt(), + i.exp(), + i.expm1(), + i.sigmoid(), + i.sin(), + i.cos(), + i.tan(), + i.sinh(), + i.cosh(), + i.tanh(), + i.atan(), + i.erf(), + j.rsqrt(), + j.reciprocal(), + j.log2(), + j.log10(), + k.asin(), + k.acos(), + (i - i).atanh(), + (i > 1).sqrt(), + (i >= 0).log(), + (i > 1).log1p(), + torch.full((2, 3), 4).sqrt(), + ] + ) + + # Small integers, so that each op reads the same ints in Core ML. + model, example_inputs = Model(), (torch.randint(0, 4, (2, 3)).float(),) + ep = torch.export.export(model, example_inputs) + delegated_program = executorch.exir.to_edge_transform_and_lower( + ep, + partitioner=[self._coreml_partitioner()], + ) + self.assertEqual( + [ + node.target.__name__ + for node in delegated_program.exported_program().graph.nodes + if node.op == "call_function" + ], + ["executorch_call_delegate", "getitem"], + ) + self._compare_outputs(delegated_program.to_executorch(), model, example_inputs) + + def test_int_input_of_ops_that_keep_it_int(self): + """ + Torch keeps an int input of floor, ceil, round, relu, relu6 and + hardtanh int, and coremltools passed it on to Core ML ops that take only + float, so lowering failed. Int bounds of hardtanh failed on a float16 + input too. + """ + + class Model(torch.nn.Module): + def forward(self, x): + # -4, 0, 4 and 8, so that relu, relu6 and hardtanh clip on both + # sides. + i = x.to(torch.int64) * 4 - 4 + ops = [i.floor(), i.ceil(), i.round(), i.relu()] + ops += [ + torch.nn.functional.relu6(i), + # Torch drops the fraction of each bound, so this clips at + # -1 and 2. + torch.nn.functional.hardtanh(i, -1.5, 2.7), + # Int bounds on a float16 input. + torch.nn.functional.hardtanh(x.half() * 4 - 4, 0, 6).long(), + torch.full((2, 3), 4).floor(), + ] + return torch.stack(ops).float() + + model, example_inputs = Model(), ( + torch.tensor([[0.0, 1.0, 2.0], [3.0, 2.0, 1.0]]), + ) + delegated_program = executorch.exir.to_edge_transform_and_lower( + torch.export.export(model, example_inputs), + partitioner=[self._coreml_partitioner()], + ) + self.assertEqual( + [ + node.target.__name__ + for node in delegated_program.exported_program().graph.nodes + if node.op == "call_function" + ], + ["executorch_call_delegate", "getitem"], + ) + self._compare_outputs(delegated_program.to_executorch(), model, example_inputs) + + def test_clamp_min_max_with_bound_of_other_dtype(self): + """ + Torch computes clamp_min and clamp_max in the dtype it promotes the + tensor and the bound to, while coremltools required the two to share a + dtype. So lowering an int bound on a float tensor failed, and so did a + float bound on an int tensor. + """ + + class Model(torch.nn.Module): + def forward(self, x): + i = (x * 2).to(torch.int64) + ops = [x.clamp_min(1), x.clamp_max(1)] + ops += [i.clamp_min(0.5), i.clamp_max(2.5)] + ops += [x.clamp_min(i[:1]), i.clamp_max(x[:1] * 2)] + ops.append(torch.full((2, 3), 0).clamp_min(0.5)) + return torch.stack(ops) + + # Values that float16 holds exactly, so that Core ML drops the same + # fractions as eager. + model, example_inputs = Model(), ((torch.rand(2, 3) * 2).half().float(),) + delegated_program = executorch.exir.to_edge_transform_and_lower( + torch.export.export(model, example_inputs), + partitioner=[self._coreml_partitioner()], + ) + self.assertEqual( + [ + node.target.__name__ + for node in delegated_program.exported_program().graph.nodes + if node.op == "call_function" + ], + ["executorch_call_delegate", "getitem"], + ) + self._compare_outputs(delegated_program.to_executorch(), model, example_inputs) + + def test_hardtanh_of_float_arange(self): + """ + coremltools gives torch.arange(6, dtype=torch.float32) the dtype int32, so + its hardtanh failed to lower. Torch clips it in float, at bounds with a + fraction. A TorchScript node carries no dtype and keeps the coremltools + converter. + """ + + class Model(torch.nn.Module): + def forward(self, x): + a = torch.arange(6, dtype=torch.float32).reshape(2, 3) + return torch.nn.functional.hardtanh(a, 0.5, 2.5) + x + + model, example_inputs = Model(), (torch.zeros(2, 3),) + ep = torch.export.export(model, example_inputs) + # With no passes, Core ML keeps the clip instead of folding it. + program = ct.convert( + ep.run_decompositions({}), + convert_to="milinternal", + pass_pipeline=ct.PassPipeline.EMPTY, + ) + self.assertEqual( + [(op.alpha.val, op.beta.val) for op in program.find_ops(op_type="clip")], + [(0.5, 2.5)], + ) + ct.convert( + torch.jit.trace(torch.nn.Hardtanh(0.5, 2.5), example_inputs), + inputs=[ct.TensorType(shape=(2, 3))], + convert_to="milinternal", + ) + delegated_program = executorch.exir.to_edge_transform_and_lower( + ep, partitioner=[self._coreml_partitioner()] + ) + self.assertEqual( + [ + node.target.__name__ + for node in delegated_program.exported_program().graph.nodes + if node.op == "call_function" + ], + ["executorch_call_delegate", "getitem"], + ) + self._compare_outputs(delegated_program.to_executorch(), model, example_inputs) + + def test_copy_into_other_dtype(self): + """ + Torch copies a value into a tensor of another dtype in the dtype of + the tensor, also for y[:, i] = v, and coremltools kept the dtype of the + value, so lowering failed. + """ + + class Model(torch.nn.Module): + def forward(self, x): + y = x * 2 + y[:, 0] = (x[:, 1] * 4).to(torch.int64) + y[:, 1] = torch.full((2,), 7) + y[:, 2] = x[:, 1] > 0.5 + z = x.clone().copy_((x * 4).to(torch.int64)) + # Torch drops the fraction of a float written into an int. + i = (x * 4).to(torch.int64) + i[:, 0] = x[:, 1] * 4 + # coremltools makes this arange int32, so the copy has to take + # the dtype from torch. + a = torch.arange(6, dtype=torch.float32).reshape(2, 3).copy_(x) + w = torch.full((2, 3), 4) + w[:, 0] = x[:, 0] * 4 + return y + torch.nn.functional.gelu(z) + i + w + a.softmax(-1) + + # Values that float16 holds exactly, so that the comparison with 0.5 in + # Core ML agrees with eager. + model, example_inputs = Model(), (torch.rand(2, 3).half().float(),) + delegated_program = executorch.exir.to_edge_transform_and_lower( + torch.export.export(model, example_inputs), + partitioner=[self._coreml_partitioner()], + ) + self.assertEqual( + [ + node.target.__name__ + for node in delegated_program.exported_program().graph.nodes + if node.op == "call_function" + ], + ["executorch_call_delegate", "getitem"], + ) + self._compare_outputs(delegated_program.to_executorch(), model, example_inputs) + def test_dequantize_affine_c4w_embedding(self): model, example_inputs = self._get_test_model() quantize_( @@ -296,14 +521,30 @@ def test_dequantize_codebook_embedding_per_grouped_row(self): self._compare_outputs(et_prog, model, example_inputs) def test__clone_dim_order_contiguous(self): + """ + The clones convert to nothing, so the Core ML program returns its own + input, and one value twice, which Core ML used to return wrong. + """ + class Model(torch.nn.Module): def forward(self, x): - return torch.ops.dim_order_ops._clone_dim_order( - x, dim_order=[0, 1, 2, 3] + y = x * 2 + return ( + torch.ops.dim_order_ops._clone_dim_order(x, dim_order=[0, 1, 2, 3]), + y, + torch.ops.dim_order_ops._clone_dim_order(y, dim_order=[0, 1, 2, 3]), ) model, example_inputs = Model(), (torch.randn(1, 3, 8, 8),) ep = torch.export.export(model, example_inputs) + mlmodel = CoreMLBackend._convert_to_mlmodel( + executorch.exir.to_edge(ep).exported_program(), + CoreMLBackend.generate_compile_specs(), + ) + description = mlmodel.get_spec().description + outputs = [output.name for output in description.output] + self.assertEqual(len(set(outputs)), len(outputs)) + self.assertFalse(set(outputs) & {i.name for i in description.input}) delegated_program = executorch.exir.to_edge_transform_and_lower( ep, partitioner=[self._coreml_partitioner()], @@ -317,6 +558,337 @@ def forward(self, x): et_prog = delegated_program.to_executorch() self._compare_outputs(et_prog, model, example_inputs) + def test_same_shape_expand_output(self): + """ + coremltools keeps a same-shape expand as a tile when it makes an + output, and Core ML on the CPU then hands back the value it expands + wrong, as for the scale_tril that MultivariateNormal returns next to + its expand. An expand that changes the shape stays a tile. + """ + + class Model(torch.nn.Module): + def __init__(self, size, expand_first): + super().__init__() + self.size = size + self.expand_first = expand_first + + def forward(self, x): + y = x * 2 + outputs = (y, y.expand(self.size, -1, -1)) + return outputs[::-1] if self.expand_first else outputs + + # Without the fix, Core ML returns y wrong on the CPU in float32. + compile_specs = CoreMLBackend.generate_compile_specs( + compute_unit=ct.ComputeUnit.CPU_ONLY, + compute_precision=ct.precision.FLOAT32, + ) + example_inputs = (torch.randn(1, 3, 8),) + for size, expand_first, op_types in ( + (1, False, ["mul", "identity"]), + (1, True, ["identity", "mul"]), + (2, False, ["mul", "tile"]), + ): + with self.subTest(size=size, expand_first=expand_first): + model = Model(size, expand_first) + ep = torch.export.export(model, example_inputs) + edge_program = executorch.exir.to_edge(ep).exported_program() + mlmodel = CoreMLBackend._convert_to_mlmodel(edge_program, compile_specs) + outputs = mlmodel._mil_program.functions["main"].outputs + self.assertEqual([output.op.op_type for output in outputs], op_types) + # Debug info attributes each output to the node that makes it. + self.assertEqual( + [ + output.op.scopes[ScopeSource.EXIR_DEBUG_HANDLE] + for output in outputs + ], + [ + [node.meta["debug_handle"]] + for node in edge_program.graph.output_node().args[0] + ], + ) + delegated_program = executorch.exir.to_edge_transform_and_lower( + ep, partitioner=[CoreMLPartitioner(compile_specs=compile_specs)] + ) + self._compare_outputs( + delegated_program.to_executorch(), model, example_inputs + ) + # The pass removes the tile it replaces, also when the pass list has + # no dead_code_elimination to do it. + mlmodel = CoreMLBackend._convert_to_mlmodel( + executorch.exir.to_edge( + torch.export.export(Model(1, False), example_inputs) + ).exported_program(), + CoreMLBackend.generate_compile_specs( + compute_precision=ct.precision.FLOAT32, + pass_names=["common::const_elimination"], + ), + ) + self.assertNotIn( + "tile", + [op.op_type for op in mlmodel._mil_program.functions["main"].operations], + ) + + def test_gelu_tanh(self): + """ + torch.export passes approximate="tanh" to gelu as a keyword, and + coremltools reads it only from the positional inputs. If the keyword + is dropped, the exact gelu runs, which differs by up to 4.7e-4. + """ + model = torch.nn.GELU(approximate="tanh").eval() + example_inputs = (torch.linspace(-8, 8, 4096),) + compile_specs = CoreMLBackend.generate_compile_specs( + compute_precision=ct.precision.FLOAT32 + ) + ep = torch.export.export(model, example_inputs) + program = ct.convert(ep.run_decompositions({}), convert_to="milinternal") + self.assertEqual( + [op.mode.val for op in program.find_ops(op_type="gelu")], + ["TANH_APPROXIMATION"], + ) + delegated_program = executorch.exir.to_edge_transform_and_lower( + ep, partitioner=[CoreMLPartitioner(compile_specs=compile_specs)] + ) + for node in delegated_program.exported_program().graph.nodes: + if node.op == "call_function": + self.assertIn( + node.target.__name__, ["executorch_call_delegate", "getitem"] + ) + et_prog = delegated_program.to_executorch() + self._compare_outputs(et_prog, model, example_inputs, atol=1e-5, rtol=1e-5) + + def test_fills_keep_their_dtype(self): + """ + coremltools makes full without a dtype, zeros and new_zeros as float, + ones_like as float from iOS 16, zeros_like with the dtype of its input, + and full_like, new_full, fill and scalar_tensor with the type of their + fill value, instead of the dtype torch gives them. nn.MultiheadAttention + turns a bool mask into a float one with zeros_like(mask, dtype=...). + """ + + class Fills(torch.nn.Module): + def forward(self, ids, half): + ones = torch.ones_like(ids) + ids.new_zeros((2, 3)) + twos = ids.new_full((2, 3), 2.0) + torch.empty_like(ids).fill_(0.0) + zeros = torch.zeros(2, 3, dtype=torch.long) + torch.full_like(ids, 0.0) + count = torch.scalar_tensor(1001.0, dtype=torch.long) + ints = (ids + ones + twos + zeros + torch.full((2, 3), 1)) * count + flags = torch.where(ids > 0, torch.full((2, 3), True), ids > 1) + return ( + ints, + flags, + half * torch.ones_like(half) + torch.zeros_like(half), + ) + + class Attention(torch.nn.MultiheadAttention): + def forward(self, x, mask): + return super().forward(x, x, x, attn_mask=mask, need_weights=False)[0] + + fills_inputs = (torch.arange(6).reshape(2, 3), torch.rand(2, 3).half()) + compile_specs = CoreMLBackend.generate_compile_specs( + compute_precision=ct.precision.FLOAT32, + minimum_deployment_target=ct.target.iOS16, + ) + delegated_program = executorch.exir.to_edge_transform_and_lower( + torch.export.export(Fills(), fills_inputs), + partitioner=[CoreMLPartitioner(compile_specs=compile_specs)], + ) + self.assertEqual( + { + node.target.__name__ + for node in delegated_program.exported_program().graph.nodes + if node.op == "call_function" + }, + {"executorch_call_delegate", "getitem"}, + ) + # The runtime casts each Core ML output to the dtype ExecuTorch expects, + # so the dtypes are read from the program the delegate converts. + lowered = delegated_program.exported_program().graph_module.lowered_module_0 + program = ct.convert( + lowered.original_module, + convert_to="milinternal", + compute_precision=ct.precision.FLOAT32, + minimum_deployment_target=ct.target.iOS16, + ) + self.assertEqual( + [output.dtype for output in program.functions["main"].outputs], + [types.int32, types.bool, types.fp16], + ) + self._compare_outputs(delegated_program.to_executorch(), Fills(), fills_inputs) + model = Attention(16, 2, batch_first=True).eval() + example_inputs = ( + torch.randn(1, 6, 16), + torch.ones(6, 6, dtype=torch.bool).triu(1), + ) + attention = torch.export.export(model, example_inputs) + program = ct.convert(attention.run_decompositions({}), convert_to="milinternal") + masks = [ + op.outputs[0].dtype + for op in program.functions["main"].operations + if op.name.startswith("zeros_like") + ] + self.assertTrue(masks and all(types.is_float(dtype) for dtype in masks)) + delegated_program = executorch.exir.to_edge_transform_and_lower( + attention, partitioner=[CoreMLPartitioner()] + ) + self.assertEqual( + [ + node.target.__name__ + for node in delegated_program.exported_program().graph.nodes + if node.op == "call_function" + ], + ["executorch_call_delegate", "getitem"], + ) + self._compare_outputs(delegated_program.to_executorch(), model, example_inputs) + + def test_empty_keeps_its_dtype(self): + """ + coremltools converts empty, new_empty and empty_like as zeros, new_zeros + and zeros_like, so they need the dtype torch gives them too. With dim + order ops skipped, as the Core ML export example does, empty reaches + Core ML as well. + """ + + class Empties(torch.nn.Module): + def forward(self, ids, x): + return ( + torch.empty(2, 3, dtype=torch.long), + ids.new_empty((2, 3)), + torch.empty_like(x, dtype=torch.long), + ids + 1, + ) + + inputs = (torch.arange(6).reshape(2, 3), torch.rand(2, 3)) + compile_specs = CoreMLBackend.generate_compile_specs( + compute_precision=ct.precision.FLOAT32 + ) + delegated_program = executorch.exir.to_edge_transform_and_lower( + torch.export.export(Empties(), inputs), + partitioner=[CoreMLPartitioner(compile_specs=compile_specs)], + compile_config=executorch.exir.EdgeCompileConfig(_skip_dim_order=True), + ) + lowered = delegated_program.exported_program().graph_module.lowered_module_0 + program = ct.convert( + lowered.original_module, + convert_to="milinternal", + compute_precision=ct.precision.FLOAT32, + ) + self.assertEqual( + [output.dtype for output in program.functions["main"].outputs], + [types.int32] * 4, + ) + + @unittest.skipIf(is_fbcode(), "The coremltools in fbcode has no rms_norm.") + def test_rms_norm_without_eps(self): + """ + torch.nn.RMSNorm(eps=None) leaves eps out of rms_norm, which + coremltools requires, and without elementwise_affine the weight too. + Torch then uses the float32 machine epsilon, which the output for a + one-hot row of 65536 values depends on. + """ + x = torch.zeros(1, 65536) + x[0, 0] = 1 + model = torch.nn.RMSNorm(65536, elementwise_affine=False).eval() + ep = torch.export.export(model, (x,)) + program = ct.convert( + ep.run_decompositions({}), + convert_to="milinternal", + compute_precision=ct.precision.FLOAT32, + ) + self.assertEqual( + [op.y.val for op in program.find_ops(op_type="add")], + [torch.finfo(torch.float32).eps], + ) + compile_specs = CoreMLBackend.generate_compile_specs( + compute_precision=ct.precision.FLOAT32 + ) + delegated_program = executorch.exir.to_edge_transform_and_lower( + ep, partitioner=[CoreMLPartitioner(compile_specs=compile_specs)] + ) + for node in delegated_program.exported_program().graph.nodes: + if node.op == "call_function": + self.assertIn( + node.target.__name__, ["executorch_call_delegate", "getitem"] + ) + et_prog = delegated_program.to_executorch() + self._compare_outputs(et_prog, model, (x,), rtol=1e-3) + + def test_negative_index_next_to_slice(self): + """ + coremltools writes y[:, i] = v to y[:, i:i + 1] without wrapping a + negative i, so for i = -1 the slice is empty: lowering failed for a + constant i, and from iOS 18 Core ML skipped the write for an input i. + """ + + class Write(torch.nn.Module): + def __init__(self): + super().__init__() + self.register_buffer("last", torch.tensor([-1])) + + def forward(self, x, v, i, rows, cols): + # Two copies, so that a skipped write shows in the output. + y = x.clone() + y[:, self.last] = v + z = x.clone() + z[:, i] = v * 2 + z[rows, cols] = v[0] + return y, z + + class Functional(torch.nn.Module): + def forward(self, x, v, i): + return torch.ops.aten.index_put(x, [None, i], v) + + model = Write() + example_inputs = ( + torch.rand(3, 4), + torch.rand(3, 1), + torch.tensor([-1]), + torch.tensor([0, 1]), + torch.tensor([-1, 2]), + ) + program = ct.convert( + torch.export.export(model, example_inputs).run_decompositions({}), + convert_to="milinternal", + minimum_deployment_target=ct.target.iOS16, + ) + # The constant is wrapped while converting, and the write without a + # slice is left to coremltools, so only the input next to a slice + # needs a select at run time. + self.assertEqual(len(program.find_ops(op_type="select")), 1) + # TorchScript passes the indices as one list, which keeps the + # coremltools converter. + ct.convert( + torch.jit.trace(Functional(), example_inputs[:3]), + inputs=[ + ct.TensorType(shape=(3, 4)), + ct.TensorType(shape=(3, 1)), + ct.TensorType(shape=(1,), dtype=types.int32), + ], + convert_to="milinternal", + ) + width = torch.export.Dim("width", min=2, max=8) + for dynamic_shapes in (None, ({1: width}, None, None, None, None)): + with self.subTest(dynamic=dynamic_shapes is not None): + ep = torch.export.export( + model, example_inputs, dynamic_shapes=dynamic_shapes + ) + delegated_program = executorch.exir.to_edge_transform_and_lower( + ep, partitioner=[self._coreml_partitioner()] + ) + self.assertEqual( + [ + node.target.__name__ + for node in delegated_program.exported_program().graph.nodes + if node.op == "call_function" + ], + ["executorch_call_delegate", "getitem", "getitem"], + ) + et_program = delegated_program.to_executorch() + self._compare_outputs(et_program, model, example_inputs) + if dynamic_shapes is not None: + # Another width catches a wrap by the exported width. + wide_inputs = (torch.rand(3, 6), *example_inputs[1:]) + self._compare_outputs(et_program, model, wide_inputs) + def test_dequantize_affine_below_ios18_raises_with_hint(self): """ Regression test for https://github.com/pytorch/executorch/issues/13122. From a8f64b0f49d6f3c8ebad6510c42ef84fd5160142 Mon Sep 17 00:00:00 2001 From: Jacob Stevens Date: Tue, 3 Nov 2026 15:22:47 -0500 Subject: [PATCH 018/128] Enable Vulkan vision runners on Android (#24503) (#24503) Summary: The Vulkan engine, its graph passes and the vision runner were only defined in fbcode, so the runner could not be built for Android. This defines them in xplat too. `backends/native/runtime/vulkan/targets.bzl` no longer returns early in xplat, `backends/native/runtime/vulkan/BUCK` defines the xplat `xplat_vulkan_runtime` next to the fbcode one, and `run_classification` and `run_classification_vulkan` are declared for both cells. The fbcode targets are unchanged. `vulkan_passes_test` needs neither a GPU nor SwiftShader, so it now also builds and runs in xplat. The xplat `run_classification` carries a CI label that builds it for Android arm64, which covers the Vulkan engine, its passes and ET-VK on Android. The new xplat targets use the default xplat platform set, which matches ET-VK's `get_platforms()`. Review fixes authored with Claude Code. Differential Revision: D129370846 --- backends/native/examples/vision/BUCK | 12 ++--- backends/native/runtime/vulkan/BUCK | 16 ++++++- backends/native/runtime/vulkan/targets.bzl | 6 +-- .../native/test/runtime/vulkan/targets.bzl | 44 +++++++++---------- 4 files changed, 43 insertions(+), 35 deletions(-) diff --git a/backends/native/examples/vision/BUCK b/backends/native/examples/vision/BUCK index 0d6fd98421d..7497e095c10 100644 --- a/backends/native/examples/vision/BUCK +++ b/backends/native/examples/vision/BUCK @@ -1,6 +1,6 @@ -load("@fbcode_macros//build_defs:build_file_migration.bzl", "fbcode_target") +load("@fbsource//tools/target_determinator/macros:ci.bzl", "ci") load("@fbsource//xplat/executorch/backends/native/runtime/engine:targets.bzl", "CPU_COMPAT") -load("@fbsource//xplat/executorch/build:runtime_wrapper.bzl", "runtime") +load("@fbsource//xplat/executorch/build:runtime_wrapper.bzl", "is_xplat", "runtime") oncall("executorch") @@ -42,8 +42,7 @@ runtime.cxx_binary( **CPU_COMPAT ) -fbcode_target( - _kind = runtime.cxx_binary, +runtime.cxx_binary( name = "run_classification", srcs = ["run_classification.cpp"], deps = [ @@ -51,11 +50,12 @@ fbcode_target( "//executorch/backends/native/runtime/vulkan:vulkan_engine", ], external_deps = ["gflags"], + # Android builds go through xplat. + labels = ci.labels(ci.linux(ci.mode("fbsource//arvr/mode/android/linux/opt"))) if is_xplat() else [], visibility = ["PUBLIC"], ) -fbcode_target( - _kind = runtime.command_alias, +runtime.command_alias( name = "run_classification_vulkan", exe = ":run_classification", visibility = ["PUBLIC"], diff --git a/backends/native/runtime/vulkan/BUCK b/backends/native/runtime/vulkan/BUCK index 5028d4ae24e..3a9f8349127 100644 --- a/backends/native/runtime/vulkan/BUCK +++ b/backends/native/runtime/vulkan/BUCK @@ -1,10 +1,12 @@ load("@fbcode_macros//build_defs:build_file_migration.bzl", "fbcode_target", "non_fbcode_target") +load("@fbsource//xplat/executorch/build:runtime_wrapper.bzl", "runtime") load(":targets.bzl", "define_common_targets") oncall("executorch") -# Link the host engine against ET-VK configured for the selected Vulkan device, -# exposing optional features such as integer dot product. +# Link the engine against ET-VK configured for the selected Vulkan device, +# exposing optional features such as integer dot product. fbcode links the +# xplat ET-VK targets directly. fbcode_target( _kind = native.cxx_library, name = "xplat_vulkan_runtime", @@ -15,6 +17,16 @@ fbcode_target( visibility = ["PUBLIC"], ) +non_fbcode_target( + _kind = runtime.cxx_library, + name = "xplat_vulkan_runtime", + exported_deps = [ + "//executorch/backends/vulkan:vulkan_compute_api", + "//executorch/backends/vulkan:vulkan_graph_runtime", + ], + visibility = ["PUBLIC"], +) + # Any targets that should be shared between fbcode and xplat must be defined in # targets.bzl. This file can contain cell-only targets. diff --git a/backends/native/runtime/vulkan/targets.bzl b/backends/native/runtime/vulkan/targets.bzl index b7a2c005071..5034644f8d0 100644 --- a/backends/native/runtime/vulkan/targets.bzl +++ b/backends/native/runtime/vulkan/targets.bzl @@ -1,4 +1,4 @@ -load("@fbsource//xplat/executorch/build:runtime_wrapper.bzl", "is_xplat", "runtime") +load("@fbsource//xplat/executorch/build:runtime_wrapper.bzl", "runtime") def define_common_targets(): runtime.cxx_library( @@ -10,10 +10,6 @@ def define_common_targets(): visibility = ["//executorch/backends/native/..."], ) - # Host-only for now; Android integration is not wired up. - if is_xplat(): - return - runtime.cxx_library( name = "vulkan_passes", srcs = [ diff --git a/backends/native/test/runtime/vulkan/targets.bzl b/backends/native/test/runtime/vulkan/targets.bzl index 0add4242c86..afb7902c023 100644 --- a/backends/native/test/runtime/vulkan/targets.bzl +++ b/backends/native/test/runtime/vulkan/targets.bzl @@ -9,6 +9,28 @@ def define_common_targets(): ], ) + runtime.cxx_test( + name = "vulkan_passes_test", + srcs = [ + "test_fuse_quantized_embedding.cpp", + "test_fuse_quantized_linear.cpp", + "test_insert_prepack.cpp", + "test_lower_hf_attention.cpp", + "test_lower_rms_norm.cpp", + "test_materialize_view_copies.cpp", + ], + deps = [ + "//executorch/backends/native/runtime/vulkan:vulkan_passes", + "//executorch/backends/native/runtime/graph:argument", + "//executorch/backends/native/runtime/graph:graph", + "//executorch/backends/native/runtime/graph:graph_utils", + "//executorch/backends/native/runtime/graph:node", + "//executorch/backends/native/runtime/graph:scalar_type", + "//executorch/backends/native/runtime/graph:value", + "//executorch/backends/native/runtime/graph:tensor_meta", + ], + ) + if is_xplat(): return @@ -48,25 +70,3 @@ def define_common_targets(): "fbsource//third-party/swiftshader/lib/linux-x64:libvk_swiftshader_fbcode", ], ) - - runtime.cxx_test( - name = "vulkan_passes_test", - srcs = [ - "test_fuse_quantized_embedding.cpp", - "test_fuse_quantized_linear.cpp", - "test_insert_prepack.cpp", - "test_lower_hf_attention.cpp", - "test_lower_rms_norm.cpp", - "test_materialize_view_copies.cpp", - ], - deps = [ - "//executorch/backends/native/runtime/vulkan:vulkan_passes", - "//executorch/backends/native/runtime/graph:argument", - "//executorch/backends/native/runtime/graph:graph", - "//executorch/backends/native/runtime/graph:graph_utils", - "//executorch/backends/native/runtime/graph:node", - "//executorch/backends/native/runtime/graph:scalar_type", - "//executorch/backends/native/runtime/graph:value", - "//executorch/backends/native/runtime/graph:tensor_meta", - ], - ) From a1772a64491603692a51b7566783ff99551ce219 Mon Sep 17 00:00:00 2001 From: mcremon-meta <134334895+mcremon-meta@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:25:10 -0800 Subject: [PATCH 019/128] Move backend-neutral Cadence passes to backends/transforms (#24469) Summary: These 23 passes in `backends/cadence/aot` are not Cadence-specific in what they do, so they move to `backends/transforms` where any backend can use them: - Three (`FuseCascadedTransposeOrPermuteOps`, `FuseCascadedViewOps`, `ReplaceNopTransposeOrPermuteWithViewPass`) were already empty subclasses of a `backends/transforms` pass, and stay as they are here. - The other 20 (including `RemoveNopAsStridedCopyOpPass`) move to `backends/transforms`, one module and target each. The ones that listed Cadence's q/dq ops alongside `quantized_decomposed`'s now take those op sets as class attributes holding only the standard ops. Cadence keeps each pass under its old name as a thin subclass that adds its own ops back, so Cadence's behavior is unchanged. - `FuseQuantDequantToRequantizePass` needs a requantize op to fuse a pair whose qparams differ, and there is no backend-neutral one. The shared pass takes a `requantize_op` (default `None`) and defaults to `allow_requantize=False`; Cadence sets its op and keeps `True`. - `permute_pass_utils` gains `get_overload_packet` and `get_placeholders`, and `quantize_reorder_utils` holds the two op sets the reorder passes share. Reviewed By: JakeStevens Differential Revision: D129173244 Pull Request resolved: https://github.com/pytorch/executorch/pull/24469 --- backends/cadence/aot/BUCK | 21 + backends/cadence/aot/fuse_ops.py | 214 +-- backends/cadence/aot/remove_ops.py | 513 +------ backends/cadence/aot/reorder_ops.py | 1348 +---------------- backends/cadence/aot/replace_ops.py | 117 +- .../advance_quantize_op_above_def_chain.py | 270 ++++ ...advance_quantize_op_above_def_in_branch.py | 196 +++ .../fuse_quant_dequant_to_requantize.py | 240 +++ .../transforms/move_permute_after_concat.py | 109 ++ .../transforms/move_slice_before_permute.py | 95 ++ backends/transforms/move_slice_before_view.py | 251 +++ backends/transforms/permute_pass_utils.py | 17 +- .../postpone_dequantize_op_below_use_chain.py | 165 ++ backends/transforms/propagate_slice.py | 222 +++ backends/transforms/quantize_reorder_utils.py | 51 + backends/transforms/remove_alias_copy_op.py | 31 + .../remove_branched_quant_dequant.py | 80 + .../transforms/remove_cat_from_slice_copy.py | 70 + .../remove_clone_ops_transform_imported.py | 26 + .../remove_nop_as_strided_copy_op.py | 52 + backends/transforms/remove_nop_expand_op.py | 36 + .../transforms/remove_nop_slice_or_view_op.py | 37 + .../transforms/remove_permute_before_mean.py | 230 +++ .../remove_permutes_around_elementwise_ops.py | 31 + .../transforms/remove_zero_sized_cat_args.py | 67 + .../transforms/replace_select_with_view_op.py | 52 + .../transforms/replace_split_with_slice.py | 89 ++ backends/transforms/split_dequantized_cat.py | 86 ++ backends/transforms/targets.bzl | 300 ++++ .../test/test_permute_optimization_passes.py | 65 + 30 files changed, 3036 insertions(+), 2045 deletions(-) create mode 100644 backends/transforms/advance_quantize_op_above_def_chain.py create mode 100644 backends/transforms/advance_quantize_op_above_def_in_branch.py create mode 100644 backends/transforms/fuse_quant_dequant_to_requantize.py create mode 100644 backends/transforms/move_permute_after_concat.py create mode 100644 backends/transforms/move_slice_before_permute.py create mode 100644 backends/transforms/move_slice_before_view.py create mode 100644 backends/transforms/postpone_dequantize_op_below_use_chain.py create mode 100644 backends/transforms/propagate_slice.py create mode 100644 backends/transforms/quantize_reorder_utils.py create mode 100644 backends/transforms/remove_alias_copy_op.py create mode 100644 backends/transforms/remove_branched_quant_dequant.py create mode 100644 backends/transforms/remove_cat_from_slice_copy.py create mode 100644 backends/transforms/remove_clone_ops_transform_imported.py create mode 100644 backends/transforms/remove_nop_as_strided_copy_op.py create mode 100644 backends/transforms/remove_nop_expand_op.py create mode 100644 backends/transforms/remove_nop_slice_or_view_op.py create mode 100644 backends/transforms/remove_permute_before_mean.py create mode 100644 backends/transforms/remove_zero_sized_cat_args.py create mode 100644 backends/transforms/replace_select_with_view_op.py create mode 100644 backends/transforms/replace_split_with_slice.py create mode 100644 backends/transforms/split_dequantized_cat.py diff --git a/backends/cadence/aot/BUCK b/backends/cadence/aot/BUCK index 765f7b7f1e9..d41fba2e71f 100644 --- a/backends/cadence/aot/BUCK +++ b/backends/cadence/aot/BUCK @@ -246,6 +246,7 @@ fbcode_target(_kind = runtime.python_library, "fuse_ops.py", ], deps = [ + "//executorch/backends/transforms:fuse_quant_dequant_to_requantize", ":compiler_utils", ":ops_registrations", "//caffe2:torch", @@ -284,6 +285,15 @@ fbcode_target(_kind = runtime.python_library, "remove_ops.py", ], deps = [ + "//executorch/backends/transforms:remove_nop_as_strided_copy_op", + "//executorch/backends/transforms:remove_alias_copy_op", + "//executorch/backends/transforms:remove_branched_quant_dequant", + "//executorch/backends/transforms:remove_cat_from_slice_copy", + "//executorch/backends/transforms:remove_clone_ops_transform_imported", + "//executorch/backends/transforms:remove_nop_expand_op", + "//executorch/backends/transforms:remove_nop_slice_or_view_op", + "//executorch/backends/transforms:remove_permute_before_mean", + "//executorch/backends/transforms:remove_zero_sized_cat_args", ":fuse_ops", ":ops_registrations", "//caffe2:torch", @@ -305,6 +315,15 @@ fbcode_target(_kind = runtime.python_library, "reorder_ops.py", ], deps = [ + ":ops_registrations", + "//executorch/backends/transforms:advance_quantize_op_above_def_in_branch", + "//executorch/backends/transforms:advance_quantize_op_above_def_chain", + "//executorch/backends/transforms:postpone_dequantize_op_below_use_chain", + "//executorch/backends/transforms:move_permute_after_concat", + "//executorch/backends/transforms:move_slice_before_permute", + "//executorch/backends/transforms:move_slice_before_view", + "//executorch/backends/transforms:propagate_slice", + "//executorch/backends/transforms:split_dequantized_cat", "//caffe2:torch", "//executorch/backends/cadence/aot:compiler_utils", "//executorch/backends/cadence/aot:pass_utils", @@ -323,6 +342,8 @@ fbcode_target(_kind = runtime.python_library, "replace_ops.py", ], deps = [ + "//executorch/backends/transforms:replace_select_with_view_op", + "//executorch/backends/transforms:replace_split_with_slice", ":pass_utils", "//caffe2:torch", "//executorch/backends/cadence/aot:compiler_utils", diff --git a/backends/cadence/aot/fuse_ops.py b/backends/cadence/aot/fuse_ops.py index 428a64bf428..32cded0e1fe 100644 --- a/backends/cadence/aot/fuse_ops.py +++ b/backends/cadence/aot/fuse_ops.py @@ -38,18 +38,16 @@ from executorch.backends.transforms.fuse_cascaded_view_ops import ( FuseCascadedViewOps as _SharedFuseCascadedViewOps, ) +from executorch.backends.transforms.fuse_quant_dequant_to_requantize import ( + FuseQuantDequantToRequantizePass as _SharedFuseQuantDequantToRequantizePass, +) from executorch.backends.transforms.fuse_transpose_or_permute_op_pairs_pass import ( FuseTransposeOrPermuteOpPairsPass as _SharedFuseTransposeOrPermuteOpPairsPass, ) -from executorch.backends.transforms.permute_pass_utils import ( - FuseOpPairsAcrossBranchesPass, -) from executorch.exir.dialects._ops import ops as exir_ops from executorch.exir.dialects.edge._ops import EdgeOpOverload, EdgeOpOverloadPacket -from executorch.exir.pass_base import PassResult from executorch.exir.passes.cse_pass import CSEPass from torch.nn.utils.fusion import fuse_conv_bn_weights -from torch.utils import _pytree as pytree def get_tensor_arg(node: torch.fx.Node, arg_name: str) -> torch.Tensor: @@ -593,23 +591,7 @@ class FuseCascadedViewOps(_SharedFuseCascadedViewOps): pass -class FuseQuantDequantToRequantizePass(FuseOpPairsAcrossBranchesPass): - """ - Fuse dequantize-quantize op pairs to a single requantize op. - For the special case where quant params match, this will remove - both dequant and quant ops. - """ - - # A list of ops that can be bypassed when looking for a - # dequantize->quantize chain - bypass_ops: set[EdgeOpOverload] = { - exir_ops.edge.aten.slice_copy.Tensor, - exir_ops.edge.aten.view_copy.default, - exir_ops.edge.aten.clone.default, - exir_ops.edge.aten.transpose_copy.int, - exir_ops.edge.aten.permute_copy.default, - } - +class FuseQuantDequantToRequantizePass(_SharedFuseQuantDequantToRequantizePass): quantize_op_packets: set[EdgeOpOverloadPacket] = { exir_ops.edge.cadence.quantize_per_tensor, exir_ops.edge.quantized_decomposed.quantize_per_tensor, @@ -618,192 +600,17 @@ class FuseQuantDequantToRequantizePass(FuseOpPairsAcrossBranchesPass): exir_ops.edge.cadence.dequantize_per_tensor, exir_ops.edge.quantized_decomposed.dequantize_per_tensor, } + requantize_op: Optional[EdgeOpOverload] = ( + exir_ops.edge.cadence.requantize.per_tensor + ) def __init__( self, allow_requantize: bool = True, force_quant_dequant_fusion: bool = False ) -> None: - super().__init__() - self.allow_requantize: bool = allow_requantize - self.force_quant_dequant_fusion: bool = force_quant_dequant_fusion - - def _pkg_name_match(self, node1: torch.fx.Node, node2: torch.fx.Node) -> bool: - # pyre-ignore[16]: Item `typing.Callable` has no attribute `_op` - return node1.target._op.namespace == node2.target._op.namespace - - def can_fuse_for_chain( - self, - producer: torch.fx.Node, - consumer: torch.fx.Node, - consumer_op_packets: set[EdgeOpOverloadPacket], - ) -> bool: - return super().can_fuse_for_chain( - producer, consumer, consumer_op_packets - ) and self._pkg_name_match(producer, consumer) - - def _create_requantize_node( - self, - in_tensor: torch.fx.Node, - in_scale: float, - in_zero_point: int, - out_scale: float, - out_zero_point: int, - out_dtype: torch.dtype, - graph: torch.fx.Graph, - ) -> torch.fx.Node: - return graph.call_function( - exir_ops.edge.cadence.requantize.per_tensor, - args=( - in_tensor, - in_scale, - in_zero_point, - out_scale, - out_zero_point, - out_dtype, - ), - ) - - def _quant_params_match(self, node1: torch.fx.Node, node2: torch.fx.Node) -> bool: - arg_names = ("scale", "zero_point", "quant_min", "quant_max", "dtype") - arg_types = (float, int, int, int, torch.dtype) - return all( - get_arg(node1, name, arg_type) == get_arg(node2, name, arg_type) - for name, arg_type in zip(arg_names, arg_types) - ) - - def check_ok_to_fuse( - self, - producer: torch.fx.Node, - consumers: list[torch.fx.Node], - ) -> bool: - """Check if all node-user pairs are nops or are ok to replace with requant.""" - for rnode in consumers: - if self.allow_requantize or self._quant_params_match(producer, rnode): - # Cannot remove quant-dequant pair if quant params don't match and requantize - # is not allowed. - continue - return False - return True - - def _get_bypassed_nodes( - self, - producer: torch.fx.Node, - removal_candidates: list[torch.fx.Node], - ) -> list[torch.fx.Node]: - """Return the bypassed ops between producer and consumers, in order.""" - candidates = set(removal_candidates) - bypassed: set[torch.fx.Node] = set() - stack = list(producer.users) - while stack: - user = stack.pop() - if user in candidates or user in bypassed: - continue - bypassed.add(user) - stack.extend(user.users) - - # The graph's node list is topologically sorted, so walking it forward - # from the producer refreshes each node after its predecessor. - ordered: list[torch.fx.Node] = [] - cursor = producer.next - while bypassed and cursor.op != "root": - if cursor in bypassed: - bypassed.remove(cursor) - ordered.append(cursor) - cursor = cursor.next - return ordered - - def fuse( - self, - node: torch.fx.Node, - removal_candidates: list[torch.fx.Node], - graph_module: torch.fx.GraphModule, - ) -> None: - # Capture the bypassed ops before the rewire erases the chain's root. - bypassed_nodes = self._get_bypassed_nodes(node, removal_candidates) - node.replace_all_uses_with(cast(torch.fx.Node, node.args[0])) - graph_module.graph.erase_node(node) - # They now consume the producer's input, so their metadata describes an - # operand they no longer have. Refresh before get_fused_node reads it. - for bypassed in bypassed_nodes: - args, kwargs = pytree.tree_map_only( - torch.fx.Node, - lambda arg: arg.meta["val"], - (bypassed.args, bypassed.kwargs), - ) - assert callable(bypassed.target) - bypassed.meta["val"] = bypassed.target(*args, **kwargs) - bypassed.meta["tensor_meta"] = None - for rnode in removal_candidates: - rnode.replace_all_uses_with(self.get_fused_node(node, rnode, graph_module)) - graph_module.graph.erase_node(rnode) - - def get_fused_node( - self, - producer: torch.fx.Node, - consumer: torch.fx.Node, - graph_module: torch.fx.GraphModule, - ) -> torch.fx.Node: - in_scale = get_arg(producer, "scale", float) - in_zero_point = get_arg(producer, "zero_point", int) - out_scale = get_arg(consumer, "scale", float) - out_zero_point = get_arg(consumer, "zero_point", int) - out_dtype = get_arg(consumer, "dtype", torch.dtype) - if in_scale == out_scale and in_zero_point == out_zero_point: - # If the quant params match, we can remove both dequantize-quantize ops. - return cast(torch.fx.Node, consumer.args[0]) - - assert ( - self.allow_requantize - ), f"Found {producer=} {in_scale=} {in_zero_point=} | {consumer=} {out_scale=} {out_zero_point=}" - - with graph_module.graph.inserting_before(consumer): - requantize_node = self._create_requantize_node( - in_tensor=cast(torch.fx.Node, consumer.args[0]), - in_scale=in_scale, - in_zero_point=in_zero_point, - out_scale=out_scale, - out_zero_point=out_zero_point, - out_dtype=out_dtype, - graph=graph_module.graph, - ) - requantize_node.meta = consumer.meta.copy() - requantize_node.meta["val"] = exir_ops.edge.cadence.requantize.per_tensor( - cast(torch.fx.Node, consumer.args[0]).meta["val"], - cast(float, in_scale), - cast(int, in_zero_point), - cast(float, out_scale), - cast(int, out_zero_point), - cast(torch.dtype, out_dtype), - ) - requantize_node.meta["tensor_meta"] = None - return requantize_node - - def call(self, graph_module: torch.fx.GraphModule) -> PassResult: - # Remove any dequantize op that has only quantize ops as its users. - modified = self.find_and_fuse( - graph_module, - producer_op_packets=self.dequantize_op_packets, - consumer_op_packets=self.quantize_op_packets, - bypass_ops=self.bypass_ops, + super().__init__( + allow_requantize=allow_requantize, + force_quant_dequant_fusion=force_quant_dequant_fusion, ) - # Remove any quantize op that has only dequantze ops as its users. - modified |= self.find_and_fuse( - graph_module, - producer_op_packets=self.quantize_op_packets, - consumer_op_packets=self.dequantize_op_packets, - # Do not requantize for quantize-dequantize pairs as this is not guaranteed - # to be better for performance/memory. - # Only fuse if all users of quant are dequant. - bypass_ops=( - self.bypass_ops - if self.force_quant_dequant_fusion - else {exir_ops.edge.aten.view_copy.default} - ), - ) - if modified: - graph_module.graph.eliminate_dead_code() - graph_module.recompile() - return PassResult(graph_module, True) - return PassResult(graph_module, False) class FuseMulScalarIntoDequantPass(RemoveOrReplacePassInterface): @@ -867,7 +674,6 @@ def targets(self) -> list[EdgeOpOverload]: return [exir_ops.edge.aten.mul.Tensor] def maybe_remove_or_replace(self, node: torch.fx.Node) -> bool: - mul_node = node if len(mul_node.users) != 1: return False diff --git a/backends/cadence/aot/remove_ops.py b/backends/cadence/aot/remove_ops.py index 78144cf583c..a39f24114cf 100644 --- a/backends/cadence/aot/remove_ops.py +++ b/backends/cadence/aot/remove_ops.py @@ -6,7 +6,7 @@ # pyre-strict -from typing import cast, List, Optional, Sequence, Set, Type +from typing import cast, List, Optional, Set, Type # Import these for the cadence function signatures. import executorch.backends.cadence.aot.ops_registrations # noqa: F401 @@ -19,11 +19,36 @@ set_arg, ) from executorch.backends.cadence.aot.simplify_ops import SimplifySliceOpPass -from executorch.backends.cadence.aot.utils import get_edge_overload_packet -from executorch.backends.transforms.remove_clone_ops import RemoveCloneOpsTransform +from executorch.backends.transforms.remove_alias_copy_op import ( + RemoveAliasCopyOpPass as _SharedRemoveAliasCopyOpPass, +) +from executorch.backends.transforms.remove_branched_quant_dequant import ( + RemoveBranchedQuantDequant as _SharedRemoveBranchedQuantDequant, +) +from executorch.backends.transforms.remove_cat_from_slice_copy import ( + RemoveCatFromSliceCopyPass as _SharedRemoveCatFromSliceCopyPass, +) +from executorch.backends.transforms.remove_clone_ops_transform_imported import ( + RemoveCloneOpsTransformImported as _SharedRemoveCloneOpsTransformImported, +) +from executorch.backends.transforms.remove_nop_as_strided_copy_op import ( + RemoveNopAsStridedCopyOpPass as _SharedRemoveNopAsStridedCopyOpPass, +) +from executorch.backends.transforms.remove_nop_expand_op import ( + RemoveNopExpandOpPass as _SharedRemoveNopExpandOpPass, +) +from executorch.backends.transforms.remove_nop_slice_or_view_op import ( + RemoveNopSliceOrViewOpPass as _SharedRemoveNopSliceOrViewOpPass, +) +from executorch.backends.transforms.remove_permute_before_mean import ( + RemovePermuteBeforeMeanPass as _SharedRemovePermuteBeforeMeanPass, +) from executorch.backends.transforms.remove_permutes_around_elementwise_ops import ( RemovePermutesAroundElementwiseOps as _SharedRemovePermutesAroundElementwiseOps, ) +from executorch.backends.transforms.remove_zero_sized_cat_args import ( + RemoveZeroSizedCatArgsPass as _SharedRemoveZeroSizedCatArgsPass, +) from executorch.backends.transforms.replace_squeeze_unsqueeze_with_view import ( ReplaceSqueezeAndUnsqueezeWithViewPass as _SharedReplaceSqueezeAndUnsqueezeWithViewPass, ) @@ -32,11 +57,8 @@ from executorch.exir.pass_base import ( ExportedProgramPassBase, ExportedProgramPassResult, - ExportPass, PassResult, ) -from executorch.exir.pass_manager import PassManager, PassType -from executorch.exir.passes import dead_code_elimination_pass from torch.export import ExportedProgram from torch.export.graph_signature import InputKind, OutputKind from torch.fx.node import Node @@ -44,14 +66,8 @@ from torch.utils import _pytree as pytree -class RemoveCloneOpsTransformImported(ExportPass): - def call(self, graph_module: torch.fx.GraphModule) -> PassResult: - finalize_passes: List[PassType] = [ - RemoveCloneOpsTransform(eliminate_quant_dequant_pairs=False), - ] - result = PassManager(passes=finalize_passes)(graph_module) - dead_code_elimination_pass(result.graph_module) - return result +class RemoveCloneOpsTransformImported(_SharedRemoveCloneOpsTransformImported): + pass class RemoveDetachCopyPass(RemoveOrReplacePassInterface): @@ -75,79 +91,12 @@ class RemoveRedundantOps: ] -class RemoveZeroSizedCatArgsPass(RemoveOrReplacePassInterface): - @property - def targets(self) -> list[EdgeOpOverload]: - return [exir_ops.edge.aten.cat.default] - - def maybe_remove_or_replace(self, node: Node) -> bool: - # Get the cat inputs (first argument is a list of tensors) - cat_inputs_arg = node.args[0] - - # Assert that cat_inputs_arg is iterable - assert isinstance( - cat_inputs_arg, (list, tuple) - ), "cat_inputs_arg must be a sequence type" - - # Filter out zero-sized tensors - cat_inputs: list[Node] = [] - for arg in cat_inputs_arg: - if isinstance(arg, Node) and arg.meta.get("val") is not None: - if arg.meta["val"].numel() > 0: - cat_inputs.append(arg) - - # If all tensors were empty, create a full op with the right shape - if not cat_inputs: - empty_shape = node.meta["val"].shape - dtype = node.meta["val"].dtype - # Create a new full node - with node.graph.inserting_before(node): - full_node = node.graph.call_function( - exir_ops.edge.aten.full.default, - args=(tuple(empty_shape), 0), - kwargs={"dtype": dtype}, - ) - full_node.meta = node.meta.copy() - node.replace_all_uses_with(full_node) - return True - - # If only one tensor remains, replace with it - if len(cat_inputs) == 1: - node.replace_all_uses_with(cat_inputs[0]) - return True - - # If the number of inputs changed, update the cat args - if len(cat_inputs) < len(cat_inputs_arg): - # Update the first argument with filtered inputs - new_args = list(node.args) - new_args[0] = cat_inputs - node.args = tuple(new_args) - return True - - # No changes needed - return False - +class RemoveZeroSizedCatArgsPass(_SharedRemoveZeroSizedCatArgsPass): + pass -class RemoveNopExpandOpPass(RemoveOrReplacePassInterface): - """ - For an expand op, if the operator shape matches the expand shape, then the - expand is a nop. - """ - @property - def targets(self) -> list[EdgeOpOverload]: - return [ - exir_ops.edge.aten.expand_copy.default, - exir_ops.edge.aten.expand.default, - ] - - def maybe_remove_or_replace(self, node: Node) -> bool: - input_node = node.args[0] - assert isinstance(input_node, Node) - if input_node.meta["val"].shape == node.meta["val"].shape: - node.replace_all_uses_with(input_node) - return True - return False +class RemoveNopExpandOpPass(_SharedRemoveNopExpandOpPass): + pass class RemoveToOpsPass(RemoveOrReplacePassInterface): @@ -191,61 +140,12 @@ def maybe_remove_or_replace(self, node: Node) -> bool: return True -class RemoveNopSliceOrViewOpPass(RemoveOrReplacePassInterface): - """ - Remove slice ops that are more like views, and view ops that do not change the shape - """ - - @property - def targets(self) -> list[EdgeOpOverload]: - return [ - exir_ops.edge.aten.slice_copy.Tensor, - exir_ops.edge.aten.view_copy.default, - ] - - def maybe_remove_or_replace(self, node: Node) -> bool: - changed = False - input_node = node.args[0] - assert isinstance(input_node, Node) - if input_node.meta["val"].shape == node.meta["val"].shape: - node.replace_all_uses_with(input_node) - changed = True - - return changed - - -class RemoveNopAsStridedCopyOpPass(RemoveOrReplacePassInterface): - """Remove as_strided_copy ops that preserve every logical element.""" - - @property - def targets(self) -> list[EdgeOpOverload]: - return [exir_ops.edge.aten.as_strided_copy.default] - - def maybe_remove_or_replace(self, node: Node) -> bool: - input_node = node.args[0] - assert isinstance(input_node, Node) - input_value = cast(torch.Tensor, input_node.meta["val"]) - size = get_arg(node, "size", list[int]) - stride = get_arg(node, "stride", list[int]) - storage_offset = get_arg(node, "storage_offset", Optional[int]) +class RemoveNopSliceOrViewOpPass(_SharedRemoveNopSliceOrViewOpPass): + pass - if tuple(size) != tuple(input_value.shape) or len(stride) != len(size): - return False - if ( - storage_offset is not None - and storage_offset != input_value.storage_offset() - ): - return False - if not all( - dim_size == 1 or output_stride == input_stride - for dim_size, output_stride, input_stride in zip( - size, stride, input_value.stride() - ) - ): - return False - node.replace_all_uses_with(input_node) - return True +class RemoveNopAsStridedCopyOpPass(_SharedRemoveNopAsStridedCopyOpPass): + pass class RemoveNopLinalgVectorNormOpPass(RemoveOrReplacePassInterface): @@ -300,21 +200,8 @@ def maybe_remove_or_replace(self, node: Node) -> bool: return True -class RemoveAliasCopyOpPass(RemoveOrReplacePassInterface): - """ - - alias_copy is a no-op and can be removed. - """ - - @property - def targets(self) -> list[EdgeOpOverload]: - return [exir_ops.edge.aten.alias_copy.default] - - def maybe_remove_or_replace(self, node: Node) -> bool: - input_node = node.args[0] - assert isinstance(input_node, Node) - node.replace_all_uses_with(input_node) - return True +class RemoveAliasCopyOpPass(_SharedRemoveAliasCopyOpPass): + pass class RemoveNopRequantizeOpPass(RemoveOrReplacePassInterface): @@ -416,217 +303,15 @@ def maybe_remove_or_replace(self, node: Node) -> bool: return False -class RemovePermuteBeforeMeanPass(RemoveOrReplacePassInterface): - """Remove or sink permute ops that precede mean reductions through unary chains. - - When a permute feeds into a mean (possibly through unary ops like - dequantize/quantize), two optimizations apply: - - 1. If non-reduced dims maintain their relative order and positions, the - permute is fully removed and the mean's reduction dims are remapped. - 2. Otherwise, the permute is moved after the mean so it operates on - smaller data. - - Cost model - ---------- - Let S_in = input size in bytes, S_out = output size in bytes, - R = S_in / S_out (reduction ratio), C_c = contiguous mean compute - cost, C_s = strided mean compute cost, and Delta = C_s - C_c. - - Original graph (permute -> mean): - Cost_orig = 3*S_in + S_out + C_c - Breakdown: permute reads and writes S_in (2*S_in), mean reads - S_in and writes S_out. - - Case 1 -- full removal (mean with remapped dims, no permute): - Cost_remove = S_in + S_out + C_s - Profitable when: Delta < 2*S_in - The strided access penalty must be less than twice the full - input tensor I/O (the eliminated permute cost). - - Case 2 -- reorder (mean with remapped dims -> small permute): - Cost_reorder = S_in + 3*S_out + C_s - Profitable when: Delta < 2*(S_in - S_out) = 2*S_in*(R-1)/R - At R = 4 the threshold is 1.5*S_in; at R = 16 it approaches - 2*S_in, converging to the full-removal bound as S_out becomes - negligible. - - Full removal always dominates reorder when both are structurally - possible (Cost_remove < Cost_reorder since S_out < 3*S_out), so - removal is applied without a cost gate. - - Additionally, if the original permute does not place the reduction - dims as the trailing (innermost) dimensions, the mean is already - strided in the original graph. Removing or sinking the permute - saves the permute I/O (2*S_in) while the mean performance can only - improve or stay the same. This makes the transformation - unconditionally profitable without needing the cost model. - """ - - _UNARY_TARGETS: frozenset[EdgeOpOverload] = frozenset( - { +class RemovePermuteBeforeMeanPass(_SharedRemovePermuteBeforeMeanPass): + _UNARY_TARGETS: frozenset[EdgeOpOverload] = ( + _SharedRemovePermuteBeforeMeanPass._UNARY_TARGETS + | { exir_ops.edge.cadence.dequantize_per_tensor.default, exir_ops.edge.cadence.quantize_per_tensor.default, - exir_ops.edge.quantized_decomposed.dequantize_per_tensor.default, - exir_ops.edge.quantized_decomposed.quantize_per_tensor.default, - exir_ops.edge.aten.clone.default, - exir_ops.edge.aten.relu.default, - exir_ops.edge.aten.neg.default, - exir_ops.edge.aten.abs.default, } ) - _MIN_REDUCTION_RATIO_FOR_REORDER: int = 4 - - @property - def targets(self) -> list[EdgeOpOverload]: - return [exir_ops.edge.aten.mean.dim] - - def _find_permute_through_unary_chain(self, mean_node: Node) -> Optional[Node]: - """Walk backward from mean through single-user unary ops to find a permute.""" - current = mean_node.args[0] - if not isinstance(current, Node): - return None - while True: - if current.target == exir_ops.edge.aten.permute_copy.default: - return current - if current.target not in self._UNARY_TARGETS: - return None - if len(current.users) != 1: - return None - parent = current.args[0] - if not isinstance(parent, Node): - return None - current = parent - - @staticmethod - def _reduction_dims_are_trailing(reduction_dims: list[int], ndim: int) -> bool: - """Check whether all reduction dims are the trailing (innermost) dims.""" - canonical = sorted(d % ndim for d in reduction_dims) - return canonical == list(range(ndim - len(canonical), ndim)) - - def _is_reorder_profitable( - self, - reduction_dims: list[int], - new_reduction_dims: list[int], - ndim: int, - input_shape: torch.Size, - ) -> bool: - """Determine whether sinking the permute past the mean is profitable. - - Three cases, in order of evaluation: - - 1. The original permute does not place the reduction dims as trailing - (innermost) dimensions. The mean is already strided, so removing - the permute saves 2*S_in with no mean degradation. Always - profitable. - - 2. The new (remapped) reduction dims are trailing in the original - layout. The mean stays contiguous after the transformation - (Delta ~ 0). Always profitable. - - 3. The mean becomes strided. The reorder is profitable when - Delta < 2*S_in*(R-1)/R. We approximate this by requiring - R >= _MIN_REDUCTION_RATIO_FOR_REORDER, ensuring the I/O savings - from operating on a smaller tensor outweigh the strided access - penalty. - """ - if not self._reduction_dims_are_trailing(reduction_dims, ndim): - return True - - if self._reduction_dims_are_trailing(new_reduction_dims, ndim): - return True - - reduction_ratio = 1 - canonical_reduction = set(new_reduction_dims) - for d in canonical_reduction: - reduction_ratio *= input_shape[d] - - return reduction_ratio >= self._MIN_REDUCTION_RATIO_FOR_REORDER - - def maybe_remove_or_replace(self, node: Node) -> bool: - reduction_dims = get_arg(node, "dim", list[int]) - - permute_node = self._find_permute_through_unary_chain(node) - if permute_node is None: - return False - - perm = get_arg(permute_node, "dims", list[int]) - ndim = len(perm) - - if len(permute_node.users) != 1: - return False - - permute_input = permute_node.args[0] - assert isinstance(permute_input, Node) - - new_reduction_dims = [perm[d] for d in reduction_dims] - keepdim = get_arg(node, "keepdim", bool) - - # Determine if the permute can be fully removed (post-mean permute - # would be a no-op) vs needing to be sunk after the mean. - canonical_reduction = set(new_reduction_dims) - if keepdim: - can_remove = all( - perm[d] == d for d in range(ndim) if d not in canonical_reduction - ) - else: - non_reduced_in_perm_order = [ - d for d in perm if d not in canonical_reduction - ] - can_remove = non_reduced_in_perm_order == sorted(non_reduced_in_perm_order) - - # Full removal is almost always profitable. Reorder requires a - # tighter cost bound; verify via the cost model before proceeding. - if not can_remove: - input_shape = permute_input.meta["val"].shape - if not self._is_reorder_profitable( - reduction_dims, new_reduction_dims, ndim, input_shape - ): - return False - - # Rewire: the permute's single user (either the mean itself or the - # first unary op in the chain) should read from the permute's input. - permute_user = next(iter(permute_node.users)) - permute_user.replace_input_with(permute_node, permute_input) - node.args = (node.args[0], new_reduction_dims) + node.args[2:] - - # Re-derive the mean's meta since its reduction dims changed. - fake_args = pytree.tree_map( - lambda x: x.meta["val"] if isinstance(x, Node) else x, - node.args, - ) - node.meta["val"] = node.target(*fake_args) # pyre-ignore[29] - - if not can_remove: - # Compute the post-mean permute on the reduced output. - if keepdim: - post_perm = list(perm) - else: - non_reduced_original = sorted( - d for d in range(ndim) if d not in canonical_reduction - ) - non_reduced_permuted = [d for d in perm if d not in canonical_reduction] - post_perm = [ - non_reduced_original.index(d) for d in non_reduced_permuted - ] - - graph = node.graph - with graph.inserting_after(node): - new_permute = graph.create_node( - "call_function", - exir_ops.edge.aten.permute_copy.default, - args=(node, post_perm), - ) - new_permute.meta["val"] = exir_ops.edge.aten.permute_copy.default( - node.meta["val"], post_perm - ) - for user in list(node.users): - if user is not new_permute: - user.replace_input_with(node, new_permute) - - return True - class ReplaceSqueezeAndUnsqueezeWithViewPassImported( _SharedReplaceSqueezeAndUnsqueezeWithViewPass @@ -776,15 +461,7 @@ def call(self, graph_module: torch.fx.GraphModule) -> PassResult: return PassResult(graph_module, False) -class RemoveBranchedQuantDequant(PassBase): - """ - This pass looks for adjacent quant and dequant nodes with identical - parameters, where the quant node has other users in addition to the - dequant. The quant and dequant pair would be removed by the - FuseQuantDequantToRequantizePass if not for the multiple users. This pass - removes just the dequant node by connecting it to the quant's parent node - """ - +class RemoveBranchedQuantDequant(_SharedRemoveBranchedQuantDequant): quantize_op_packets: set[EdgeOpOverloadPacket] = { exir_ops.edge.cadence.quantize_per_tensor, exir_ops.edge.quantized_decomposed.quantize_per_tensor, @@ -794,105 +471,9 @@ class RemoveBranchedQuantDequant(PassBase): exir_ops.edge.quantized_decomposed.dequantize_per_tensor, } - def call(self, graph_module: torch.fx.GraphModule) -> PassResult: - modified = self.remove_branched( - graph_module, self.quantize_op_packets, self.dequantize_op_packets - ) - modified |= self.remove_branched( - graph_module, self.dequantize_op_packets, self.quantize_op_packets - ) - - if modified: - graph_module.graph.eliminate_dead_code() - graph_module.recompile() - - return PassResult(graph_module, modified) - - def remove_branched( - self, - graph_module: torch.fx.GraphModule, - producer_pkts: set[EdgeOpOverloadPacket], - consumer_pkts: set[EdgeOpOverloadPacket], - ) -> bool: - modified = False - for node in graph_module.graph.nodes: - if ( - node.op != "call_function" - or not isinstance(node.target, EdgeOpOverload) - or get_edge_overload_packet(node.target) not in producer_pkts - ): - continue - - if len(node.users) < 2: - continue - - for user in node.users: - if ( - not isinstance(user.target, EdgeOpOverload) - or get_edge_overload_packet(user.target) not in consumer_pkts - ): - continue - - # check qparams match - if node.args[1:] != user.args[1:]: - continue - - user.replace_all_uses_with(node.args[0]) - modified = True - return modified - - -class RemoveCatFromSliceCopyPass(RemoveOrReplacePassInterface): - """ - Simplifies cat->slice_copy chains where one of the cat inputs can be directly passed - to the slice_copy. - """ - - @property - def targets(self) -> list[EdgeOpOverload]: - return [exir_ops.edge.aten.slice_copy.Tensor] - - def maybe_remove_or_replace(self, node: Node) -> bool: - cat_node = get_arg(node, "input", Node) - slice_dim = get_arg(node, "dim", int) - start_idx = get_arg(node, "start", Optional[int]) - end_idx = get_arg(node, "end", Optional[int]) - step = get_arg(node, "step", int) - - if cat_node.target != exir_ops.edge.aten.cat.default or step != 1: - return False - - # Make sure cat and slice happens on the same dimension. - cat_dim = get_arg(cat_node, "dim", int) - if cat_dim != slice_dim: - return False - - # Canonicalize slice indices. - cat_output_shape = cat_node.meta["val"].shape - if start_idx is None: - start_idx = 0 - elif start_idx < 0: - start_idx += cat_output_shape[cat_dim] - if end_idx is None or end_idx > cat_output_shape[cat_dim]: - end_idx = cat_output_shape[cat_dim] - elif end_idx < 0: - end_idx += cat_output_shape[cat_dim] - - offset = 0 - for cat_input_node in get_arg(cat_node, "tensors", Sequence[Node]): - cat_input_shape = cat_input_node.meta["val"].shape - - # Check if the slice range overlaps with the cat input range. - if offset <= start_idx and end_idx <= offset + cat_input_shape[cat_dim]: - node.replace_input_with(cat_node, cat_input_node) - set_arg(node, "start", start_idx - offset) - set_arg(node, "end", end_idx - offset) - return True - - offset += cat_input_shape[cat_dim] - - return False +class RemoveCatFromSliceCopyPass(_SharedRemoveCatFromSliceCopyPass): + pass class CommonRemovePasses: diff --git a/backends/cadence/aot/reorder_ops.py b/backends/cadence/aot/reorder_ops.py index 4ee7bb193d6..23f7759b572 100644 --- a/backends/cadence/aot/reorder_ops.py +++ b/backends/cadence/aot/reorder_ops.py @@ -9,629 +9,79 @@ # This file contains all the functions that reorder ops in the graph module. -from collections import defaultdict -from math import prod -from typing import Callable, cast, DefaultDict, List, Optional, Tuple +# The Cadence pass subclasses below reference torch.ops.cadence.* at class +# definition, so the op library must be registered first. +import executorch.backends.cadence.aot.ops_registrations # noqa: F401 import torch import torch.fx -from executorch.backends.cadence.aot.compiler_utils import get_placeholders, get_shape -from executorch.backends.cadence.aot.pass_utils import ( - get_arg, - get_overload_packet, - RemoveOrReplacePassInterface, +from executorch.backends.cadence.aot.compiler_utils import get_placeholders +from executorch.backends.cadence.aot.pass_utils import RemoveOrReplacePassInterface +from executorch.backends.transforms.advance_quantize_op_above_def_chain import ( + AdvanceQuantizeOpAboveDefChainPass as _SharedAdvanceQuantizeOpAboveDefChainPass, +) +from executorch.backends.transforms.advance_quantize_op_above_def_in_branch import ( + AdvanceQuantizeOpAboveDefInBranchPass as _SharedAdvanceQuantizeOpAboveDefInBranchPass, +) +from executorch.backends.transforms.move_permute_after_concat import ( + MovePermuteAfterConcat as _SharedMovePermuteAfterConcat, +) +from executorch.backends.transforms.move_slice_before_permute import ( + MoveSliceBeforePermutePass as _SharedMoveSliceBeforePermutePass, +) +from executorch.backends.transforms.move_slice_before_view import ( + MoveSliceBeforeViewPass as _SharedMoveSliceBeforeViewPass, +) +from executorch.backends.transforms.postpone_dequantize_op_below_use_chain import ( + PostponeDequantizeOpBelowUseChainPass as _SharedPostponeDequantizeOpBelowUseChainPass, ) -from executorch.backends.cadence.aot.utils import get_edge_overload_packet from executorch.backends.transforms.postpone_permute_below_squeeze_view import ( PostponePermuteOpBelowSqueezeOrUnsqueezeLikeView as _SharedPostponePermuteOpBelowSqueezeOrUnsqueezeLikeView, ) +from executorch.backends.transforms.propagate_slice import ( + PropagateSlice as _SharedPropagateSlice, +) +from executorch.backends.transforms.split_dequantized_cat import ( + SplitDequantizedCatPass as _SharedSplitDequantizedCatPass, +) from executorch.exir.dialects._ops import ops as exir_ops from executorch.exir.dialects.edge._ops import EdgeOpOverload -from executorch.exir.pass_base import ExportPass, PassResult -from executorch.exir.tensor import num_bytes_from_shape_and_dtype - -# A list of ops that can be trivially quantized -trivially_quantizable_ops_overloadpkt = { - exir_ops.edge.aten.chunk, - exir_ops.edge.aten.clone, - exir_ops.edge.aten.contiguous, - exir_ops.edge.aten.expand_copy, - exir_ops.edge.aten.permute_copy, - exir_ops.edge.aten.select_copy, - exir_ops.edge.aten.slice_copy, - exir_ops.edge.aten.squeeze_copy, - exir_ops.edge.aten.transpose_copy, - exir_ops.edge.aten.unfold_copy, - exir_ops.edge.aten.unsqueeze_copy, - exir_ops.edge.aten.view_copy, - torch.ops.aten.chunk, - torch.ops.aten.clone, - torch.ops.aten.contiguous, - torch.ops.aten.expand_copy, - torch.ops.aten.permute, - torch.ops.aten.permute_copy, - torch.ops.aten.select_copy, - torch.ops.aten.slice, - torch.ops.aten.slice_copy, - torch.ops.aten.squeeze, - torch.ops.aten.squeeze_copy, - torch.ops.aten.transpose, - torch.ops.aten.transpose_copy, - torch.ops.aten.unsqueeze, - torch.ops.aten.unsqueeze_copy, - torch.ops.aten.view, - torch.ops.aten.view_copy, -} - -# slice-equivalent ops -slice_or_select_overloadpkt = { - torch.ops.aten.slice_copy, - torch.ops.aten.select_copy, - exir_ops.edge.aten.slice_copy, - exir_ops.edge.aten.select_copy, -} - -supported_quantize_ops_overloadpkt = { - exir_ops.edge.quantized_decomposed.quantize_per_tensor, - exir_ops.edge.quantized_decomposed.quantize_per_channel, - exir_ops.edge.cadence.quantize_per_tensor, -} - - -class AdvanceQuantizeOpAboveDefInBranchPass(ExportPass): - """ - If the graph is branched with the following pattern: - I = ... - S1 = slice(I) - Q1 = quantize(S1) - S2 = slice(I) - Q2 = quantize(S2) - S3 = slice(I) - Q3 = quantize(S3) - ... - such that the elements in the slices S1 + S2 + S3 is greater than I, - we can advance the quantize above their defs (i.e., all the slice nodes), - and reorder the pattern to the following: - I = ... - Q1 = quantize(I) - S1 = slice(Q1) - Q1 = requantize(S1) - S2 = slice(Q1) - Q2 = requantize(S2) - S3 = slice(Q1) - Q3 = requantize(S3) - ... - Note that the other passes won't do this transformation because they expect - a linear chain of def-use, which is not true here; the uses of I are - branched. - """ - - def __init__(self): - super().__init__() - self.graph_module = None - - # Starting at node, iterate through its successors, bypassing any trivially - # quantizable op. If all the descendents are quantize ops, return them. - def get_descendent_quant_ops(self, node: torch.fx.Node) -> List[torch.fx.Node]: - # The list of quant ops that are descendents of node, such that the only - # nodes in the path from node --> quant are trivially quantizable ops. - descendent_quant_ops = [] - # The list of trivially quantizable ops in the path from node --> quant op. - trivial_quantized_ops = [] - - users = list(node.users.keys()) - while users: - user = users.pop(0) - user_target = get_overload_packet(user.target) - # Record a quant op successor - if user_target in { - torch.ops.quantized_decomposed.quantize_per_tensor, - exir_ops.edge.quantized_decomposed.quantize_per_tensor, - torch.ops.cadence.quantize_per_tensor, - exir_ops.edge.cadence.quantize_per_tensor, - }: - descendent_quant_ops.append(user) - # If the successor is a trivially quantizable op, consider its users - # instead. - elif user_target in trivially_quantizable_ops_overloadpkt: - trivial_quantized_ops.append(user) - users.extend(list(user.users.keys())) - # Otherwise all successors of node are not quant op, so break the loop. - else: - descendent_quant_ops.clear() - break - - # If all the nodes in trivial_quantize_ops of the node were slice ops, - # ensure that the advance is still profitable. - if descendent_quant_ops and all( - get_overload_packet(x.target) in slice_or_select_overloadpkt - for x in trivial_quantized_ops - ): - # Profitability metric: the sum of all the output slices must be - # more than half the input node size. - slice_sizes = [ - prod(list(y)) - for x in trivial_quantized_ops - if (y := get_shape(self.graph_module, x)) is not None - ] - node_shape = get_shape(self.graph_module, node) - node_size = prod(list(node_shape)) if node_shape is not None else 0 - if node_size >= 2 * sum(slice_sizes): - descendent_quant_ops.clear() - - return descendent_quant_ops - - def advance_quantize_op(self, graph_module: torch.fx.GraphModule): - graph = graph_module.graph - for node in graph.nodes: - # We are only interested in call functions and placeholders - if node.op not in {"placeholder", "call_function"}: - continue - # If the node is trivially quantizable, skip it - if ( - get_overload_packet(node.target) - in trivially_quantizable_ops_overloadpkt - ): - continue - # Get the descendent quant ops that are connected to the current - # node via trivially quantizable ops. - descendent_quant_ops = self.get_descendent_quant_ops(node) - if not descendent_quant_ops: - continue - - # Get the insertion point below which we need to insert anything. - # if node is a placeholder, we will only insert a new node after - # all the placeholders in the graph. - insertion_pt = ( - get_placeholders(graph)[-1] if node.op == "placeholder" else node - ) - - # If the node only has a single quant op as descendent, we can - # simply hoist the quant op below the current node as its single - # child. - if len(descendent_quant_ops) == 1: - quant_node = descendent_quant_ops.pop() - # Replace the uses of quant node with its predecessor - quant_node.replace_all_uses_with(quant_node.args[0]) # pyre-fixme[6] - # Hoist the quant node after the current node. Make sure that - # the insertion is after placeholders - with graph.inserting_after(insertion_pt): - dom_quant_args = (node,) + quant_node.args[1:] - dom_quant_node = graph.call_function( - exir_ops.edge.quantized_decomposed.quantize_per_tensor.default - ) - dom_quant_node.meta = node.meta - node.replace_all_uses_with(dom_quant_node) - dom_quant_node.args = dom_quant_args - graph.erase_node(quant_node) - continue - - # Otherwise we have multiple quant descendents. Requantizing them from - # one hoisted quantize is only lossless if they all share qparams, so - # skip when they differ. - quant_args = { - tuple(quant_node.args[1:]) for quant_node in descendent_quant_ops - } - if len(quant_args) > 1: - continue - (rep_args,) = quant_args - - # Create a new quant node that dominates all the nodes in - # descendent_quant_ops. Make sure that the insertion is after - # all the placeholders. - with graph.inserting_after(insertion_pt): - dom_quant_args = (node,) + rep_args - dom_quant_node = graph.call_function( - exir_ops.edge.quantized_decomposed.quantize_per_tensor.default - ) - dom_quant_node.meta = node.meta - node.replace_all_uses_with(dom_quant_node) - dom_quant_node.args = dom_quant_args - - # Finally, convert each of the quant node to a dequant/quant pair that - # requantizes the data flowing through dom_quant_node. - # TODO: Once requantize is implemented for PT2, replace the - # dequant/quant pair here with a single requantize node - for quant_node in descendent_quant_ops: - with graph.inserting_before(quant_node): - dequant_node = graph.call_function( - exir_ops.edge.quantized_decomposed.dequantize_per_tensor.default - ) - dequant_node.args = (quant_node.args[0],) + rep_args - quant_node.args = (dequant_node,) + quant_node.args[1:] - - graph_module.recompile() - graph_module.graph.eliminate_dead_code() - - def call(self, graph_module: torch.fx.GraphModule) -> PassResult: - self.graph_module = graph_module - self.advance_quantize_op(graph_module) - result = super().call(graph_module) - return result - - -class AdvanceQuantizeOpAboveDefChainPass(ExportPass): - """ - Advances a quantize op above data-movement ops to reduce data volume. - - Handles two cases: - - 1. Linear chain: if the input to a quantize op is a chain of trivially - quantizable ops (view, transpose, permute, slice), rewrite - data_movement(fp32) -> quantize to quantize -> data_movement(quantized) - so the data movement operates on smaller quantized tensors. - - 2. Cat: if the input to a quantize op is a cat with a single user (the - quantize), advance the quantize above the cat by quantizing each cat - input individually. A later pass can clean up any redundant - dequant-quant pairs on the inputs. - - 3. Caller-supplied ops: advance the quantize above a value-preserving op by - quantizing selected direct floating-point tensor inputs. The caller is - responsible for supplying only inputs that support the quantized dtype. - - For the cat case, SplitDequantizedCatPass should run first to ensure - each cat has at most one quantize consumer. - """ - - def __init__( - self, - extra_quantizable_ops: dict[EdgeOpOverload, tuple[int, ...]] | None = None, - ) -> None: - super().__init__() - self.graph_module = None - self._extra_quantizable_ops = extra_quantizable_ops or {} - - # Return true if advancing the quantize node is feasible - def advancing_feasible(self, quant_node: torch.fx.Node): - assert quant_node.op == "call_function" and len(quant_node.args) >= 1 - # Get the input of the quant node. Only proceed if it's a torch node. - inp = quant_node.args[0] - if not isinstance(inp, torch.fx.Node): - return False - - # Return false if the input to the quantize node is (1) not trivially - # quantizable, or (2) has more than one user. - inp_users = list(inp.users.keys()) - inp_overloadpkt = None - if isinstance(inp.target, EdgeOpOverload): - inp_overloadpkt = get_edge_overload_packet(inp.target) - else: - inp_overloadpkt = get_overload_packet(inp.target) - - if ( - inp_overloadpkt not in trivially_quantizable_ops_overloadpkt - or len(inp_users) != 1 - ): - return False - - # Advancing quantize op above slice nodes is tricky. If we advance the - # quantize node above slice, then we will quantize the input to the slice - # op, which can be expensive. We only bypass nop slice at present. - if inp_overloadpkt in slice_or_select_overloadpkt: - sliced_tensor = inp.args[0] - assert isinstance(sliced_tensor, torch.fx.Node) - slice_input_shape = get_shape(self.graph_module, sliced_tensor) - slice_output_shape = get_shape(self.graph_module, inp) - # If we could not glean the shapes, or the slice op is a nop, bail - if ( - slice_output_shape is None - or slice_input_shape is None - or prod(list(slice_output_shape)) < prod(list(slice_input_shape)) - ): - return False - # All the conditions satisfied, we advance. - return True - - def _advance_above_cat( - self, quant_node: torch.fx.Node, cat_node: torch.fx.Node - ) -> None: - """Advance a quantize op above a cat by quantizing each cat input.""" - graph = quant_node.graph - quant_params = quant_node.args[1:] - - cat_inputs = cat_node.args[0] - assert isinstance(cat_inputs, (list, tuple)) - - new_inputs: list[torch.fx.Node] = [] - for inp in cat_inputs: - # cat concatenates tensors, so every input must be a node. - assert isinstance(inp, torch.fx.Node) - - with graph.inserting_before(cat_node): - new_quant = graph.call_function( - # pyre-ignore[6] - quant_node.target, - args=(inp, *quant_params), - ) - # This copies the fp32 input's meta, so meta["val"] keeps the - # fp32 dtype rather than the quantized output dtype. That's fine: - # nothing in this pass reads dtype from meta (only shape, which - # is correct), and call() re-runs super().call() to re-propagate - # fake tensors, making meta dtype-consistent before we return. - new_quant.meta = inp.meta.copy() - new_inputs.append(new_quant) - - dim = get_arg(cat_node, "dim", int) - with graph.inserting_before(quant_node): - new_cat = graph.call_function( - # pyre-ignore[6] - cat_node.target, - args=(new_inputs, dim), - ) - new_cat.meta = quant_node.meta.copy() - - quant_node.replace_all_uses_with(new_cat) - graph.erase_node(quant_node) - - def _advance_above_extra_quantizable_op( - self, - quant_node: torch.fx.Node, - op_node: torch.fx.Node, - quantizable_input_indices: tuple[int, ...], - ) -> bool: - graph = quant_node.graph - new_op_args = list(op_node.args) - quantized_input = False - - for index in quantizable_input_indices: - assert 0 <= index < len(op_node.args) - arg = op_node.args[index] - assert isinstance(arg, torch.fx.Node) - value = arg.meta["val"] - assert isinstance(value, torch.Tensor) - if not value.dtype.is_floating_point: - continue - - quant_args = list(quant_node.args) - quant_args[0] = arg - with graph.inserting_before(op_node): - new_quant = graph.call_function( - # pyre-ignore[6] - quant_node.target, - args=tuple(quant_args), - kwargs=quant_node.kwargs, - ) - # We will correct the dtype when we run - # ExportPass call at the end. - new_quant.meta = arg.meta.copy() - new_op_args[index] = new_quant - quantized_input = True - - if not quantized_input: - return False - - with graph.inserting_before(quant_node): - new_op = graph.call_function( - # pyre-ignore[6] - op_node.target, - args=tuple(new_op_args), - kwargs=op_node.kwargs, - ) - new_op.meta = quant_node.meta.copy() - - quant_node.replace_all_uses_with(new_op) - graph.erase_node(quant_node) - return True - def advance_quantize_op(self, graph_module: torch.fx.GraphModule) -> bool: - graph = graph_module.graph - modified = False - for node in reversed(graph.nodes): - if get_overload_packet(node.target) not in ( - exir_ops.edge.quantized_decomposed.quantize_per_tensor, - torch.ops.quantized_decomposed.quantize_per_tensor, - exir_ops.edge.cadence.quantize_per_tensor, - torch.ops.cadence.quantize_per_tensor, - ): - continue - - inp = node.args[0] - if ( - isinstance(inp, torch.fx.Node) - and get_overload_packet(inp.target) - in (exir_ops.edge.aten.cat, torch.ops.aten.cat) - and len(inp.users) == 1 - ): - self._advance_above_cat(node, inp) - modified = True - continue - - if ( - isinstance(inp, torch.fx.Node) - and inp.target in self._extra_quantizable_ops - and len(inp.users) == 1 - and self._advance_above_extra_quantizable_op( - node, - inp, - self._extra_quantizable_ops[inp.target], - ) - ): - modified = True - continue - - if not self.advancing_feasible(node): - continue - - trivially_quantizable_op = node.args[0] - # The input to the quant node must now be the input to the trivially - # quantizable op. - quant_args = list(node.args) - quant_args[0] = trivially_quantizable_op.args[0] - - # Insert the new quant node with updated args before the current - # quant node. - with graph.inserting_before(node): - quant_node = graph.call_function(node.target, args=tuple(quant_args)) - quant_node.meta = node.meta - # Move the trivially quantizable node after the quant node - with graph.inserting_after(node): - tq_args = list(trivially_quantizable_op.args) - tq_args[0] = quant_node - tq_node = graph.call_function( - trivially_quantizable_op.target, - args=tuple(tq_args), - kwargs=trivially_quantizable_op.kwargs, - ) - tq_node.meta = trivially_quantizable_op.meta - # Replace all uses of node with newly created tq_node - node.replace_all_uses_with(tq_node) - # We can safely remove the quant node and trivially quantizable op - graph.erase_node(node) - graph.erase_node(trivially_quantizable_op) - modified = True - - return modified - - def call(self, graph_module: torch.fx.GraphModule) -> PassResult: - self.graph_module = graph_module - modified = self.advance_quantize_op(graph_module) - if modified: - graph_module.recompile() - graph_module.graph.eliminate_dead_code() - return super().call(graph_module) - - return PassResult(graph_module, False) - - -class PostponeDequantizeOpBelowUseChainPass(ExportPass): - """ - If the consumer of dequantize is a linear chain of view, transpose, permute, - or slice ops that are trivially quantized, we can convert the pattern - dequantize(int8/uint8) -> view/transpose/permute/slice(fp32) to - view/transpose/permute/slice(int8/uint8) -> dequantize(int8/uint8) - The benefit of such reordering is that the view/transpose/permute/slice - will move far less data. - """ - - def __init__(self): - super().__init__() - self.graph_module = None - - # Return true if postponing the dequantize node is feasible - def postponing_feasible(self, dequant_node: torch.fx.Node): - users = list(dequant_node.users.keys()) - # Check if the dequantize op has a single user, and that user is - # trivially quantizable. - trivially_quantizable_users = all( - get_overload_packet(user.target) in trivially_quantizable_ops_overloadpkt - for user in users - ) - if len(users) == 1: - return trivially_quantizable_users - - # Otherwise check if all the users are slice op - if not all( - get_overload_packet(user.target) in slice_or_select_overloadpkt - for user in users - ): - return False - - quantized_branches = [] - for user in users: - slice_users = list(user.users) - quantized_branches.append( - bool(slice_users) - and all( - slice_user.op == "call_function" - and get_overload_packet(slice_user.target) - in supported_quantize_ops_overloadpkt - for slice_user in slice_users - ) - ) - - # Preserve the existing fallback for forks whose branches all requantize. - if all(quantized_branches): - return True - - dequant_shape = get_shape(self.graph_module, dequant_node) - if dequant_shape is None: - return False - - dequant_bytes = num_bytes_from_shape_and_dtype(dequant_shape, torch.float32) - surviving_dequant_bytes = 0 - for user, quantized_branch in zip(users, quantized_branches): - if quantized_branch: - continue - slice_shape = get_shape(self.graph_module, user) - if slice_shape is None: - return False - # Nop slices are removed later and do not add another materialized copy. - if prod(list(slice_shape)) == prod(list(dequant_shape)): - continue - surviving_dequant_bytes += num_bytes_from_shape_and_dtype( - slice_shape, torch.float32 - ) - - return surviving_dequant_bytes <= dequant_bytes - - def postpone_dequantize_op(self, graph_module: torch.fx.GraphModule) -> bool: - # Different supported dequant ops have their own default variants - packet_to_overload_map = { - exir_ops.edge.quantized_decomposed.dequantize_per_tensor: "default", - torch.ops.cadence.dequantize_per_tensor: "default", - exir_ops.edge.quantized_decomposed.dequantize_per_channel: "default", - exir_ops.edge.cadence.dequantize_per_tensor: "default", - } - graph = graph_module.graph - modified = False - for node in graph.nodes: - overload_packet = get_overload_packet(node.target) - if ( - overload_packet not in packet_to_overload_map.keys() - or not self.postponing_feasible(node) - ): - continue - - for user in node.users: - with graph.inserting_after(user): - dequant_node = graph.call_function( - getattr( - overload_packet, packet_to_overload_map[overload_packet] - ), - args=(user, *node.args[1:]), - ) - dequant_node.meta = user.meta.copy() - # Remove meta["debug_handle"] on new node if it exists. - # Reassign it at the caller level by calling generate_missing_debug_handles - dequant_node.meta.pop("debug_handle", None) - user.replace_all_uses_with(dequant_node) - dequant_node.args = (user, *node.args[1:]) - - pred = node.args[0] - node.replace_all_uses_with(pred) - graph.erase_node(node) - modified = True - - if modified: - graph_module.recompile() - return modified - - def call(self, graph_module: torch.fx.GraphModule) -> PassResult: - # The logic in postpone_dequantize_op that handles branching checks the shape - # of the dequant node, which isn't available if that node was already postponed - # in the same pass invokation. The shape information is recreated by tracing in - # super().call(), meaning that every branch in the graph that we wish to postpone - # dequant past requires retracing. We iterate the pass until it no longer modifies - # the graph (up to 3 times max, to avoid potential infinite loops) - self.graph_module = graph_module - iter_count = 0 - local_modified = False - overall_modified = False +class AdvanceQuantizeOpAboveDefInBranchPass( + _SharedAdvanceQuantizeOpAboveDefInBranchPass +): + quantize_op_packets: set[object] = { + torch.ops.quantized_decomposed.quantize_per_tensor, + exir_ops.edge.quantized_decomposed.quantize_per_tensor, + torch.ops.cadence.quantize_per_tensor, + exir_ops.edge.cadence.quantize_per_tensor, + } - while local_modified or iter_count == 0: - local_modified = self.postpone_dequantize_op(self.graph_module) - overall_modified |= local_modified - if local_modified: - self.graph_module = super().call(self.graph_module).graph_module +class AdvanceQuantizeOpAboveDefChainPass(_SharedAdvanceQuantizeOpAboveDefChainPass): + quantize_op_packets: set[object] = { + exir_ops.edge.quantized_decomposed.quantize_per_tensor, + torch.ops.quantized_decomposed.quantize_per_tensor, + exir_ops.edge.cadence.quantize_per_tensor, + torch.ops.cadence.quantize_per_tensor, + } - iter_count += 1 - if iter_count == 3: - break - return PassResult(self.graph_module, overall_modified) +class PostponeDequantizeOpBelowUseChainPass( + _SharedPostponeDequantizeOpBelowUseChainPass +): + quantize_op_packets: set[object] = { + exir_ops.edge.quantized_decomposed.quantize_per_tensor, + exir_ops.edge.quantized_decomposed.quantize_per_channel, + exir_ops.edge.cadence.quantize_per_tensor, + } + dequantize_packet_to_overload: dict[object, str] = { + exir_ops.edge.quantized_decomposed.dequantize_per_tensor: "default", + torch.ops.cadence.dequantize_per_tensor: "default", + exir_ops.edge.quantized_decomposed.dequantize_per_channel: "default", + exir_ops.edge.cadence.dequantize_per_tensor: "default", + } class SinkOpsCloserToUsePass(RemoveOrReplacePassInterface): @@ -774,674 +224,36 @@ class PostponePermuteOpBelowSqueezeOrUnsqueezeLikeView( pass -class MovePermuteAfterConcat(RemoveOrReplacePassInterface): - """Move matching single-use permutes after a cat. - - For example, - - cat( - [ - permute(x[80, 16, 32], [1, 2, 0]), # [16, 32, 80] - permute(y[1, 16, 32], [1, 2, 0]), # [16, 32, 1] - ], - dim=2, - ) # [16, 32, 81] - - becomes - - permute(cat([x, y], dim=0), [1, 2, 0]) - # cat: [81, 16, 32], output: [16, 32, 81] - """ - - @property - def targets(self) -> list[EdgeOpOverload]: - return [exir_ops.edge.aten.cat.default] - - @staticmethod - def _match_inputs( - cat_node: torch.fx.Node, - ) -> Optional[Tuple[List[torch.fx.Node], Tuple[int, ...]]]: - inputs = get_arg( - cat_node, - "tensors", - # pyre-ignore[6] - list[torch.fx.Node] | tuple[torch.fx.Node, ...], - ) - # Moving one reused permute after cat makes it process the larger output. - if inputs and all(inp is inputs[0] for inp in inputs): - return None - - unpermuted_inputs: List[torch.fx.Node] = [] - common_permutation: Optional[Tuple[int, ...]] = None - for permute_node in inputs: - if ( - permute_node.target != exir_ops.edge.aten.permute_copy.default - or len(permute_node.users) != 1 - ): - return None - - permute_input = cast(torch.fx.Node, permute_node.args[0]) - dims = get_arg( - permute_node, - "dims", - # pyre-ignore[6] - list[int] | tuple[int, ...], - ) - rank = len(dims) - permutation = tuple(dim % rank for dim in dims) - if common_permutation is None: - common_permutation = permutation - elif permutation != common_permutation: - return None - unpermuted_inputs.append(permute_input) - - return unpermuted_inputs, cast(Tuple[int, ...], common_permutation) - - def maybe_remove_or_replace(self, node: torch.fx.Node) -> bool: - match = self._match_inputs(node) - if match is None: - return False - inputs, permutation = match - - output_cat_dim = get_arg(node, "dim", int) % len(permutation) - input_cat_dim = permutation[output_cat_dim] - graph = node.graph - - with graph.inserting_before(node): - new_cat = graph.call_function( - exir_ops.edge.aten.cat.default, - args=(inputs, input_cat_dim), - ) - new_cat.meta["val"] = exir_ops.edge.aten.cat.default( - [inp.meta["val"] for inp in inputs], input_cat_dim - ) - new_permute = graph.call_function( - exir_ops.edge.aten.permute_copy.default, - args=(new_cat, list(permutation)), - ) - new_permute.meta = node.meta.copy() - - node.replace_all_uses_with(new_permute) - return True - - -class MoveSliceBeforePermutePass(RemoveOrReplacePassInterface): - """Move slice_copy ops before permute_copy to reduce permute data volume. - - Rewrites permute(input, perm) -> slice(dim=D) into - slice(input, dim=perm[D]) -> permute(sliced, perm), so the permute - operates on a smaller tensor. - - Scans slice nodes and matches upstream permutes. This also handles - chained cases (permute -> slice -> slice) in one pass: each slice - independently checks its input for a permute. - - Cost model: dim-0 slices are nop-eligible (zero-copy pointer offset - after MakeSliceAndCatDimOutermostPass). Moving such a slice loses the - nop, so we only move it when the permute savings outweigh the nop loss, - i.e. when the slice removes more than half the data (full > 2 * sliced). - Non-dim-0 slices have no nop opportunity, so any permute savings is - pure win. - """ - - STRIDED_SLICE_COST_FACTOR: int = 2 - - @property - def targets(self) -> list[EdgeOpOverload]: - return [exir_ops.edge.aten.slice_copy.Tensor] - - def maybe_remove_or_replace(self, node: torch.fx.Node) -> bool: - permute_node = get_arg(node, "input", torch.fx.Node) - if permute_node.target != exir_ops.edge.aten.permute_copy.default: - return False - - if len(permute_node.users) != 1: - return False - - perm = cast(list[int], permute_node.args[1]) - permute_input = permute_node.args[0] - assert isinstance(permute_input, torch.fx.Node) - - slice_dim = get_arg(node, "dim", int) - - full_size = prod(permute_node.meta["val"].shape) - sliced_size = prod(node.meta["val"].shape) - if slice_dim == 0 and full_size <= self.STRIDED_SLICE_COST_FACTOR * sliced_size: - return False - - new_dim = perm[slice_dim] - graph = node.graph - - with graph.inserting_before(permute_node): - new_slice_args = ( - permute_input, - new_dim, - get_arg(node, "start"), - get_arg(node, "end"), - get_arg(node, "step", int), - ) - new_slice = graph.create_node( - "call_function", - exir_ops.edge.aten.slice_copy.Tensor, - args=new_slice_args, - ) - new_slice.meta["val"] = exir_ops.edge.aten.slice_copy.Tensor( - permute_input.meta["val"], *new_slice_args[1:] - ) - new_permute = graph.create_node( - "call_function", - exir_ops.edge.aten.permute_copy.default, - args=(new_slice, perm), - ) - new_permute.meta["val"] = exir_ops.edge.aten.permute_copy.default( - new_slice.meta["val"], perm - ) - - node.replace_all_uses_with(new_permute) - return True - - -class MoveSliceBeforeViewPass(RemoveOrReplacePassInterface): - """Move a slice_copy above a view_copy when the slice is re-expressible as a - single slice on one dim of the pre-view tensor. - - Rewrites view(x) -> slice(dim=d, start, end, step) into - slice(x, dim=d', start', end', step') -> view(sliced, slice_out_shape), so the - slice lands directly on x. This may be useful in attention patterns, where - we view outputs of a large linear into a new shape where the number of - attention heads are the last dim, and we need to run independent computation - per head. Moving the slice before the view can allow us to then directly slice - the constant linear weights. - - A view is a contiguous reshape: it never moves or reorders elements, it only - re-groups the shared row-major index space into different dims. A slice keeps - an arithmetic progression of indices (start, start+step, ...) along one viewed - dim, and that progression collapses back to a *single* slice on one pre-view - dim exactly when the row-major strides line up. ``_derive_pre_view_slice`` - handles the three cases that qualify: - - * untouched dim: the viewed dim is left unchanged by the view -- same size - and same inner stride as some pre-view dim -- so the slice copies over - verbatim (any step). - * contiguous: the viewed dim and a pre-view dim span the same flat extent - (a split's outermost factor, or a merge that aligns), so a contiguous - (step==1) slice maps to a contiguous pre-view slice. - * strided: the viewed dim is an innermost factor of a pre-view dim - (identical inner stride) selected width-1, so it maps to a strided - pre-view slice with step == the viewed dim's size. - - Everything else -- middle factors, wider strided selections -- is block-strided - (runs separated by gaps), which no single slice can express, so it is left - unchanged. - - Each slice is handled independently, so a view that fans out to several slices - is rewritten one slice at a time and the now-dead view is removed by dead-code - elimination -- there is no single-user requirement on the view. - """ - - @property - def targets(self) -> list[EdgeOpOverload]: - return [exir_ops.edge.aten.slice_copy.Tensor] - - def maybe_remove_or_replace(self, node: torch.fx.Node) -> bool: - view_node = get_arg(node, "input", torch.fx.Node) - if view_node.target != exir_ops.edge.aten.view_copy.default: - return False - - x_node = get_arg(view_node, "input", torch.fx.Node) - pre_view_shape = tuple(x_node.meta["val"].shape) - post_view_shape = tuple(view_node.meta["val"].shape) - if 0 in pre_view_shape or 0 in post_view_shape: - return False - - dim = get_arg(node, "dim", int) - if dim < 0: - dim += len(post_view_shape) - post_view_size = post_view_shape[dim] - - bounds = self._normalize_slice(node, post_view_size) - if bounds is None: - return False - start, stop, step = bounds - - # The slice's own output shape gives the selected-element count along the - # sliced dim directly -- it is exactly output_shape[dim]. - slice_out_shape = tuple(node.meta["val"].shape) - post_view_count = slice_out_shape[dim] - if post_view_count == 0: - return False - - # Row-major stride of the sliced viewed dim, and of every pre-view dim. - post_view_stride = prod(post_view_shape[dim + 1 :]) - pre_view_strides = self._row_major_strides(pre_view_shape) - - derived = self._derive_pre_view_slice( - pre_view_shape, - pre_view_strides, - post_view_stride, - post_view_size, - start, - stop, - step, - post_view_count, - ) - if derived is None: - return False - pre_view_dim, pre_view_start, pre_view_stop, pre_view_step = derived - - graph = node.graph - with graph.inserting_before(node): - new_slice_args = ( - x_node, - pre_view_dim, - pre_view_start, - pre_view_stop, - pre_view_step, - ) - new_slice = graph.create_node( - "call_function", - exir_ops.edge.aten.slice_copy.Tensor, - args=new_slice_args, - ) - new_slice.meta["val"] = exir_ops.edge.aten.slice_copy.Tensor( - x_node.meta["val"], *new_slice_args[1:] - ) - new_view = graph.create_node( - "call_function", - exir_ops.edge.aten.view_copy.default, - args=(new_slice, list(slice_out_shape)), - ) - new_view.meta["val"] = exir_ops.edge.aten.view_copy.default( - new_slice.meta["val"], list(slice_out_shape) - ) - - node.replace_all_uses_with(new_view) - return True - - @staticmethod - def _row_major_strides(shape: tuple[int, ...]) -> list[int]: - """Row-major (contiguous) strides for ``shape``.""" - strides = [1] * len(shape) - acc = 1 - for i in range(len(shape) - 1, -1, -1): - strides[i] = acc - acc *= shape[i] - return strides - - def _normalize_slice( - self, node: torch.fx.Node, post_view_size: int - ) -> Optional[tuple[int, int, int]]: - """Resolve the slice to concrete, clamped ``(start, stop, step)`` ints, or - None if the bounds are dynamic or the step is non-positive (neither of - which this pass handles).""" - step = get_arg(node, "step") - - if not isinstance(step, int): - return None - - if step <= 0: - return None - - raw_start = get_arg(node, "start") - raw_stop = get_arg(node, "end") - - # Make sure raw_start/raw_stop are not symbolic. - if (raw_start is not None and not isinstance(raw_start, int)) or ( - raw_stop is not None and not isinstance(raw_stop, int) - ): - return None - - start = 0 if raw_start is None else raw_start - stop = post_view_size if raw_stop is None else raw_stop - if start < 0: - start += post_view_size - if stop < 0: - stop += post_view_size - start = max(0, min(start, post_view_size)) - stop = max(0, min(stop, post_view_size)) - return start, stop, step - - def _derive_pre_view_slice( - self, - pre_view_shape: tuple[int, ...], - pre_view_strides: list[int], - post_view_stride: int, - post_view_size: int, - start: int, - stop: int, - step: int, - post_view_count: int, - ) -> tuple[int, int, int, int] | None: - """Return ``(dim, start, stop, step)`` for the single pre-view-tensor slice - equivalent to slicing the viewed dim, or None if no single pre-view slice - reproduces it. - - Both shapes index the same row-major flat space, so the sliced viewed dim - (size ``post_view_size``, inner stride ``post_view_stride``) lines up with - one pre-view dim (size ``pre_view_size``, inner stride ``pre_view_stride``) - in one of three ways. - """ - for pre_view_dim, (pre_view_stride, pre_view_size) in enumerate( - zip(pre_view_strides, pre_view_shape) - ): - # Untouched: the viewed dim is identical to this pre-view dim (same - # size and same inner stride), so the slice applies verbatim, any step. - if pre_view_stride == post_view_stride and pre_view_size == post_view_size: - return pre_view_dim, start, stop, step - - # Contiguous: the viewed dim and this pre-view dim span the same flat - # extent (same period), and the selected band aligns to this dim's - # boundaries. A contiguous (step==1) viewed slice - # [start, start+post_view_count) is the flat band [start* - # post_view_stride, (start+post_view_count)*post_view_stride), a - # contiguous slice on this pre-view dim iff both ends are multiples of - # its stride. - if ( - step == 1 - and post_view_size * post_view_stride == pre_view_size * pre_view_stride - ): - flat_start = start * post_view_stride - flat_stop = (start + post_view_count) * post_view_stride - if ( - flat_start % pre_view_stride == 0 - and flat_stop % pre_view_stride == 0 - ): - return ( - pre_view_dim, - flat_start // pre_view_stride, - flat_stop // pre_view_stride, - 1, - ) - - # Strided is the ONLY way the reshape itself introduces a stride, and - # it requires a width-1 selection (post_view_count == 1): the viewed - # dim is an innermost factor of this pre-view dim (identical inner - # stride), so fixing that single factor index and letting the rest of - # the pre-view dim run yields a uniform stride equal to the viewed dim's - # size. Any wider selection (post_view_count > 1) of an inner factor - # leaves runs separated by gaps -- block-strided, not a single slice -- - # so width-1 is required. - if ( - post_view_count == 1 - and post_view_size > 1 - and pre_view_stride == post_view_stride - and pre_view_size % post_view_size == 0 - ): - pre_view_count = pre_view_size // post_view_size - pre_view_stop = start + (pre_view_count - 1) * post_view_size + 1 - return pre_view_dim, start, pre_view_stop, post_view_size - return None - - -class PropagateSlice(RemoveOrReplacePassInterface): - """Propagate slice_copy before element-wise ops when the cost model - indicates it reduces total data movement. - - Supported ops (extensible via dispatch table): - - quantize_per_tensor: unary element-wise - - dequantize_per_tensor: unary element-wise - - add.Tensor: binary with broadcast — slices non-broadcasting inputs - - mul.Tensor: binary with broadcast — slices non-broadcasting inputs - - Callers can provide per-target filters for additional legality constraints. - Handles any slice dim and any step size. - """ - - def __init__( - self, - additional_unary_targets: Optional[list[EdgeOpOverload]] = None, - additional_binary_targets: Optional[list[EdgeOpOverload]] = None, - target_filters: Optional[ - dict[EdgeOpOverload, Callable[[torch.fx.Node], bool]] - ] = None, - ) -> None: - super().__init__() - unary_targets = [ - exir_ops.edge.quantized_decomposed.quantize_per_tensor.default, - exir_ops.edge.cadence.quantize_per_tensor.default, - exir_ops.edge.quantized_decomposed.dequantize_per_tensor.default, - exir_ops.edge.cadence.dequantize_per_tensor.default, - *(additional_unary_targets or []), - ] - binary_targets = [ - exir_ops.edge.aten.add.Tensor, - exir_ops.edge.aten.mul.Tensor, - *(additional_binary_targets or []), - ] - self._dispatch: dict[ - EdgeOpOverload, - tuple[ - Callable[[torch.fx.Node, torch.fx.Node], bool], - Callable[[torch.fx.Node, torch.fx.Node], bool], - ], - ] = {} - self._target_filters = target_filters or {} - for t in unary_targets: - self._dispatch[t] = ( - self._should_swap_elementwise, - self._swap_elementwise_slice, - ) - - for t in binary_targets: - self._dispatch[t] = ( - self._should_swap_binary_elementwise, - self._swap_binary_elementwise_slice, - ) - - @property - def targets(self) -> list[EdgeOpOverload]: - return [exir_ops.edge.aten.slice_copy.Tensor] - - def _should_swap_elementwise( - self, op_node: torch.fx.Node, slice_node: torch.fx.Node - ) -> bool: - full_size = prod(op_node.meta["val"].shape) - sliced_size = prod(slice_node.meta["val"].shape) - return sliced_size < full_size - - def _swap_elementwise_slice( - self, op_node: torch.fx.Node, slice_node: torch.fx.Node - ) -> bool: - op_input = op_node.args[0] - assert isinstance(op_input, torch.fx.Node) - graph = slice_node.graph - - slice_dim = get_arg(slice_node, "dim", int) - slice_start = get_arg(slice_node, "start") - slice_end = get_arg(slice_node, "end") - slice_step = get_arg(slice_node, "step", int) - - with graph.inserting_before(op_node): - new_slice = graph.call_function( - exir_ops.edge.aten.slice_copy.Tensor, - args=(op_input, slice_dim, slice_start, slice_end, slice_step), - ) - new_slice.meta["val"] = exir_ops.edge.aten.slice_copy.Tensor( - op_input.meta["val"], slice_dim, slice_start, slice_end, slice_step - ) - - new_args = list(op_node.args) - new_args[0] = new_slice - target = cast(EdgeOpOverload, op_node.target) - new_op = graph.call_function( - target, - args=tuple(new_args), - kwargs=op_node.kwargs, - ) - new_op.meta["val"] = target( - new_slice.meta["val"], - *[ - a.meta["val"] if isinstance(a, torch.fx.Node) else a - for a in new_args[1:] - ], - **{ - k: v.meta["val"] if isinstance(v, torch.fx.Node) else v - for k, v in op_node.kwargs.items() - }, - ) - - slice_node.replace_all_uses_with(new_op) - graph.erase_node(slice_node) - graph.erase_node(op_node) - return True - - def _should_swap_binary_elementwise( - self, op_node: torch.fx.Node, slice_node: torch.fx.Node - ) -> bool: - lhs, rhs = op_node.args[0], op_node.args[1] - assert isinstance(lhs, torch.fx.Node) and isinstance(rhs, torch.fx.Node) - if lhs.meta["val"].shape == rhs.meta["val"].shape: - return False - full_size = prod(op_node.meta["val"].shape) - sliced_size = prod(slice_node.meta["val"].shape) - return sliced_size < full_size - - def _swap_binary_elementwise_slice( - self, op_node: torch.fx.Node, slice_node: torch.fx.Node - ) -> bool: - lhs, rhs = op_node.args[0], op_node.args[1] - assert isinstance(lhs, torch.fx.Node) and isinstance(rhs, torch.fx.Node) - graph = slice_node.graph - - slice_dim = get_arg(slice_node, "dim", int) - slice_start = get_arg(slice_node, "start") - slice_end = get_arg(slice_node, "end") - slice_step = get_arg(slice_node, "step", int) - - output_shape = op_node.meta["val"].shape - output_dim = slice_dim % len(output_shape) - - new_args = list(op_node.args) - with graph.inserting_before(op_node): - for i, inp in enumerate([lhs, rhs]): - input_shape = inp.meta["val"].shape - # Broadcasting aligns operand dimensions to the right of the output. - input_dim = output_dim - (len(output_shape) - len(input_shape)) - if ( - input_dim >= 0 - and input_shape[input_dim] == output_shape[output_dim] - ): - new_slice = graph.call_function( - exir_ops.edge.aten.slice_copy.Tensor, - args=(inp, input_dim, slice_start, slice_end, slice_step), - ) - new_slice.meta["val"] = exir_ops.edge.aten.slice_copy.Tensor( - inp.meta["val"], - input_dim, - slice_start, - slice_end, - slice_step, - ) - new_args[i] = new_slice - - target = cast(EdgeOpOverload, op_node.target) - new_op = graph.call_function( - target, - args=tuple(new_args), - kwargs=op_node.kwargs, - ) - new_op.meta["val"] = target( - *[ - a.meta["val"] if isinstance(a, torch.fx.Node) else a - for a in new_args - ], - **{ - k: v.meta["val"] if isinstance(v, torch.fx.Node) else v - for k, v in op_node.kwargs.items() - }, - ) - - slice_node.replace_all_uses_with(new_op) - graph.erase_node(slice_node) - graph.erase_node(op_node) - return True - - def maybe_remove_or_replace(self, node: torch.fx.Node) -> bool: - parent = get_arg(node, "input", torch.fx.Node) - if len(parent.users) != 1: - return False - if not isinstance(parent.target, EdgeOpOverload): - return False - - entry = self._dispatch.get(parent.target) - if entry is None: - return False - target_filter = self._target_filters.get(parent.target) - if target_filter is not None and not target_filter(parent): - return False - - should_swap, do_swap = entry - return should_swap(parent, node) and do_swap(parent, node) - - -_QUANT_OVERLOAD_PACKETS = { - exir_ops.edge.quantized_decomposed.quantize_per_tensor, - exir_ops.edge.cadence.quantize_per_tensor, -} - -_DEQUANT_OVERLOAD_PACKETS = { - exir_ops.edge.quantized_decomposed.dequantize_per_tensor, - exir_ops.edge.cadence.dequantize_per_tensor, -} - - -class SplitDequantizedCatPass(RemoveOrReplacePassInterface): - """Split a cat node so that quantize consumers get their own copy. +class MovePermuteAfterConcat(_SharedMovePermuteAfterConcat): + pass - Fires when a cat has all floating-point inputs, at least one dequantize - input, and at least one quantize consumer. Quant consumers are grouped - by matching qparams; each group receives a dedicated duplicate of the - cat node. Non-quant consumers stay on the original cat, whose - semantics are unchanged. - A later pass (e.g. AdvanceQuantizeOpAboveDefChainPass extended for cat) - can then hoist each quant above its single-consumer cat copy without - affecting the non-quant paths. - """ - - @property - def targets(self) -> list[EdgeOpOverload]: - return [exir_ops.edge.aten.cat.default] - - def maybe_remove_or_replace(self, node: torch.fx.Node) -> bool: - cat_inputs = node.args[0] - if not isinstance(cat_inputs, (list, tuple)): - return False - - has_dequant_input = False - for inp in cat_inputs: - assert isinstance(inp, torch.fx.Node) - val = inp.meta["val"] - if val is None or not val.is_floating_point(): - return False - if get_overload_packet(inp.target) in _DEQUANT_OVERLOAD_PACKETS: - has_dequant_input = True +class MoveSliceBeforePermutePass(_SharedMoveSliceBeforePermutePass): + pass - if not has_dequant_input: - return False - quant_groups: DefaultDict[Tuple, List[torch.fx.Node]] = defaultdict(list) - for user in list(node.users.keys()): - if get_overload_packet(user.target) in _QUANT_OVERLOAD_PACKETS: - quant_groups[user.args[1:]].append(user) +class MoveSliceBeforeViewPass(_SharedMoveSliceBeforeViewPass): + pass - if not quant_groups: - return False - graph = node.graph - dim = get_arg(node, "dim", int) - for quant_consumers in quant_groups.values(): - with graph.inserting_after(node): - dup_cat = graph.call_function( - exir_ops.edge.aten.cat.default, - args=(list(cat_inputs), dim), - ) - dup_cat.meta = node.meta.copy() +class PropagateSlice(_SharedPropagateSlice): + quant_unary_targets: list[EdgeOpOverload] = [ + exir_ops.edge.quantized_decomposed.quantize_per_tensor.default, + exir_ops.edge.cadence.quantize_per_tensor.default, + exir_ops.edge.quantized_decomposed.dequantize_per_tensor.default, + exir_ops.edge.cadence.dequantize_per_tensor.default, + ] - for q_node in quant_consumers: - q_node.replace_input_with(node, dup_cat) - return True +class SplitDequantizedCatPass(_SharedSplitDequantizedCatPass): + quantize_op_packets: set[object] = { + exir_ops.edge.quantized_decomposed.quantize_per_tensor, + exir_ops.edge.cadence.quantize_per_tensor, + } + dequantize_op_packets: set[object] = { + exir_ops.edge.quantized_decomposed.dequantize_per_tensor, + exir_ops.edge.cadence.dequantize_per_tensor, + } # The following class consolidates functions to reoder ops (i.e., either hoist diff --git a/backends/cadence/aot/replace_ops.py b/backends/cadence/aot/replace_ops.py index bc04f749491..bd11ee81b79 100644 --- a/backends/cadence/aot/replace_ops.py +++ b/backends/cadence/aot/replace_ops.py @@ -13,9 +13,8 @@ import logging import math -import operator from operator import neg -from typing import cast, Dict, Optional, Sequence +from typing import cast, Dict, Sequence import torch import torch.fx @@ -32,6 +31,12 @@ from executorch.backends.transforms.replace_scalar_with_tensor import ( ReplaceScalarWithTensorArgPass, ) +from executorch.backends.transforms.replace_select_with_view_op import ( + ReplaceSelectWithViewOpPass as _SharedReplaceSelectWithViewOpPass, +) +from executorch.backends.transforms.replace_split_with_slice import ( + ReplaceSplitWithSlicePass as _SharedReplaceSplitWithSlicePass, +) from executorch.backends.transforms.replace_squeeze_unsqueeze_with_view import ( ReplaceSqueezeAndUnsqueezeWithViewPass as _SharedReplaceSqueezeAndUnsqueezeWithViewPass, ) @@ -224,41 +229,8 @@ def maybe_remove_or_replace(self, node: torch.fx.Node) -> bool: return True -class ReplaceSelectWithViewOpPass(RemoveOrReplacePassInterface): - """ - If the size along the select dim is 1, then the select op can be replaced - by view op. - """ - - @property - def targets(self) -> list[EdgeOpOverload]: - return [exir_ops.edge.aten.select_copy.int] - - def maybe_remove_or_replace(self, node: torch.fx.Node) -> bool: - # Get the input tensor and shapes - in_tensor_node = node.args[0] - assert isinstance(in_tensor_node, torch.fx.Node) - in_shape = in_tensor_node.meta["val"].shape - out_shape = node.meta["val"].shape - - # Get the select dimension - select_dim = node.args[1] - assert isinstance(select_dim, int) - select_dim = select_dim if select_dim >= 0 else select_dim + len(in_shape) - - if in_shape[select_dim] == 1: - # Replace with view op with the new shape - with node.graph.inserting_before(node): - new_node = node.graph.call_function( - exir_ops.edge.aten.view_copy.default, - args=(node.args[0], list(out_shape)), - ) - # Important to copy metadata - new_node.meta = node.meta - node.replace_all_uses_with(new_node) - return True - - return False +class ReplaceSelectWithViewOpPass(_SharedReplaceSelectWithViewOpPass): + pass class ReplaceMMWithAddMMPass(RemoveOrReplacePassInterface): @@ -2179,75 +2151,8 @@ def maybe_remove_or_replace(self, node: torch.fx.Node) -> bool: # Adapted from fbcode/pyspeech/opt_passes/replace_ops.py -class ReplaceSplitWithSlicePass(RemoveOrReplacePassInterface): - """ - split_with_sizes() delegates to slice() op, so perform this replacement here. - This avoids the expense of delegation from ATen. - """ - - @property - def targets(self) -> list[EdgeOpOverload]: - return [exir_ops.edge.aten.split_with_sizes_copy.default] - - def maybe_remove_or_replace(self, node: torch.fx.Node) -> bool: - # All the users of this split_with_sizes op must be getitem ops - if any(user.target != operator.getitem for user in node.users): - return False - - # Get the slice dim and extent for each split - slice_ops = self._get_split_sizes(node) - if slice_ops is None: - return False - - graph = node.graph - - # Go over each getitem user, and replace it with slice op - for user in list(node.users.keys()): - assert user.target == operator.getitem - item_idx = int(user.args[1]) - assert item_idx < len(slice_ops) - cur_slice = slice_ops[item_idx] - with graph.inserting_before(user): - cur_slice_node = graph.call_function( - exir_ops.edge.aten.slice_copy.Tensor, - (node.args[0], cur_slice[0], cur_slice[1], cur_slice[2], 1), - ) - # Metadata copy important - cur_slice_node.meta = user.meta - user.replace_all_uses_with(cur_slice_node) - - # Return True to indicate the split node should be removed - return True - - def _get_split_sizes(self, node: torch.fx.Node) -> Optional[list[tuple[int, ...]]]: - """For split_with_sizes, return the slice dim and extent for each split.""" - # Parse the args of the split_with_sizes op - tensor_arg, split_sizes = node.args[0:2] - assert isinstance(tensor_arg, torch.fx.Node) - - # Get shape from node metadata - val = tensor_arg.meta.get("val") - if val is None: - return None - in_shape = val.shape - - split_dim = 0 if len(node.args) < 3 else node.args[2] - - # Canonicalize the split dimension - assert isinstance(split_dim, int) - split_dim = split_dim if split_dim >= 0 else len(in_shape) + split_dim - - # Create the slice op args corresponding to each split - slice_ops = [] - split_start = 0 - assert isinstance(split_sizes, list) - for split_size in split_sizes: - split_end = split_start + split_size - slice_args = (split_dim, split_start, split_end) - slice_ops.append(slice_args) - split_start = split_end - - return slice_ops +class ReplaceSplitWithSlicePass(_SharedReplaceSplitWithSlicePass): + pass class ReplacePowWithMulPass(RemoveOrReplacePassInterface): diff --git a/backends/transforms/advance_quantize_op_above_def_chain.py b/backends/transforms/advance_quantize_op_above_def_chain.py new file mode 100644 index 00000000000..5e029e9ac3f --- /dev/null +++ b/backends/transforms/advance_quantize_op_above_def_chain.py @@ -0,0 +1,270 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# pyre-unsafe + +from math import prod + +import torch +import torch.fx +from executorch.backends.transforms.permute_pass_utils import ( + get_arg, + get_edge_overload_packet, + get_overload_packet, + get_shape, +) +from executorch.backends.transforms.quantize_reorder_utils import ( + slice_or_select_overloadpkt, + trivially_quantizable_ops_overloadpkt, +) +from executorch.exir.dialects._ops import ops as exir_ops +from executorch.exir.dialects.edge._ops import EdgeOpOverload +from executorch.exir.pass_base import ExportPass, PassResult + + +class AdvanceQuantizeOpAboveDefChainPass(ExportPass): + """ + Advances a quantize op above data-movement ops to reduce data volume. + + Handles two cases: + + 1. Linear chain: if the input to a quantize op is a chain of trivially + quantizable ops (view, transpose, permute, slice), rewrite + data_movement(fp32) -> quantize to quantize -> data_movement(quantized) + so the data movement operates on smaller quantized tensors. + + 2. Cat: if the input to a quantize op is a cat with a single user (the + quantize), advance the quantize above the cat by quantizing each cat + input individually. A later pass can clean up any redundant + dequant-quant pairs on the inputs. + + 3. Caller-supplied ops: advance the quantize above a value-preserving op by + quantizing selected direct floating-point tensor inputs. The caller is + responsible for supplying only inputs that support the quantized dtype. + + For the cat case, SplitDequantizedCatPass should run first to ensure + each cat has at most one quantize consumer. + """ + + quantize_op_packets: set[object] = { + exir_ops.edge.quantized_decomposed.quantize_per_tensor, + torch.ops.quantized_decomposed.quantize_per_tensor, + } + + def __init__( + self, + extra_quantizable_ops: dict[EdgeOpOverload, tuple[int, ...]] | None = None, + ) -> None: + super().__init__() + self.graph_module = None + self._extra_quantizable_ops = extra_quantizable_ops or {} + + # Return true if advancing the quantize node is feasible + def advancing_feasible(self, quant_node: torch.fx.Node): + assert quant_node.op == "call_function" and len(quant_node.args) >= 1 + # Get the input of the quant node. Only proceed if it's a torch node. + inp = quant_node.args[0] + if not isinstance(inp, torch.fx.Node): + return False + + # Return false if the input to the quantize node is (1) not trivially + # quantizable, or (2) has more than one user. + inp_users = list(inp.users.keys()) + inp_overloadpkt = None + if isinstance(inp.target, EdgeOpOverload): + inp_overloadpkt = get_edge_overload_packet(inp.target) + else: + inp_overloadpkt = get_overload_packet(inp.target) + + if ( + inp_overloadpkt not in trivially_quantizable_ops_overloadpkt + or len(inp_users) != 1 + ): + return False + + # Advancing quantize op above slice nodes is tricky. If we advance the + # quantize node above slice, then we will quantize the input to the slice + # op, which can be expensive. We only bypass nop slice at present. + if inp_overloadpkt in slice_or_select_overloadpkt: + sliced_tensor = inp.args[0] + assert isinstance(sliced_tensor, torch.fx.Node) + slice_input_shape = get_shape(self.graph_module, sliced_tensor) + slice_output_shape = get_shape(self.graph_module, inp) + # If we could not glean the shapes, or the slice op is a nop, bail + if ( + slice_output_shape is None + or slice_input_shape is None + or prod(list(slice_output_shape)) < prod(list(slice_input_shape)) + ): + return False + + # All the conditions satisfied, we advance. + return True + + def _advance_above_cat( + self, quant_node: torch.fx.Node, cat_node: torch.fx.Node + ) -> None: + """Advance a quantize op above a cat by quantizing each cat input.""" + graph = quant_node.graph + quant_params = quant_node.args[1:] + + cat_inputs = cat_node.args[0] + assert isinstance(cat_inputs, (list, tuple)) + + new_inputs: list[torch.fx.Node] = [] + for inp in cat_inputs: + # cat concatenates tensors, so every input must be a node. + assert isinstance(inp, torch.fx.Node) + + with graph.inserting_before(cat_node): + new_quant = graph.call_function( + # pyre-ignore[6] + quant_node.target, + args=(inp, *quant_params), + ) + # This copies the fp32 input's meta, so meta["val"] keeps the + # fp32 dtype rather than the quantized output dtype. That's fine: + # nothing in this pass reads dtype from meta (only shape, which + # is correct), and call() re-runs super().call() to re-propagate + # fake tensors, making meta dtype-consistent before we return. + new_quant.meta = inp.meta.copy() + new_inputs.append(new_quant) + + dim = get_arg(cat_node, "dim", int) + with graph.inserting_before(quant_node): + new_cat = graph.call_function( + # pyre-ignore[6] + cat_node.target, + args=(new_inputs, dim), + ) + new_cat.meta = quant_node.meta.copy() + + quant_node.replace_all_uses_with(new_cat) + graph.erase_node(quant_node) + + def _advance_above_extra_quantizable_op( + self, + quant_node: torch.fx.Node, + op_node: torch.fx.Node, + quantizable_input_indices: tuple[int, ...], + ) -> bool: + graph = quant_node.graph + new_op_args = list(op_node.args) + quantized_input = False + + for index in quantizable_input_indices: + assert 0 <= index < len(op_node.args) + arg = op_node.args[index] + assert isinstance(arg, torch.fx.Node) + value = arg.meta["val"] + assert isinstance(value, torch.Tensor) + if not value.dtype.is_floating_point: + continue + + quant_args = list(quant_node.args) + quant_args[0] = arg + with graph.inserting_before(op_node): + new_quant = graph.call_function( + # pyre-ignore[6] + quant_node.target, + args=tuple(quant_args), + kwargs=quant_node.kwargs, + ) + # We will correct the dtype when we run + # ExportPass call at the end. + new_quant.meta = arg.meta.copy() + new_op_args[index] = new_quant + quantized_input = True + + if not quantized_input: + return False + + with graph.inserting_before(quant_node): + new_op = graph.call_function( + # pyre-ignore[6] + op_node.target, + args=tuple(new_op_args), + kwargs=op_node.kwargs, + ) + new_op.meta = quant_node.meta.copy() + + quant_node.replace_all_uses_with(new_op) + graph.erase_node(quant_node) + return True + + def advance_quantize_op(self, graph_module: torch.fx.GraphModule) -> bool: + graph = graph_module.graph + modified = False + for node in reversed(graph.nodes): + if get_overload_packet(node.target) not in self.quantize_op_packets: + continue + + inp = node.args[0] + if ( + isinstance(inp, torch.fx.Node) + and get_overload_packet(inp.target) + in (exir_ops.edge.aten.cat, torch.ops.aten.cat) + and len(inp.users) == 1 + ): + self._advance_above_cat(node, inp) + modified = True + continue + + if ( + isinstance(inp, torch.fx.Node) + and inp.target in self._extra_quantizable_ops + and len(inp.users) == 1 + and self._advance_above_extra_quantizable_op( + node, + inp, + self._extra_quantizable_ops[inp.target], + ) + ): + modified = True + continue + + if not self.advancing_feasible(node): + continue + + trivially_quantizable_op = node.args[0] + # The input to the quant node must now be the input to the trivially + # quantizable op. + quant_args = list(node.args) + quant_args[0] = trivially_quantizable_op.args[0] + + # Insert the new quant node with updated args before the current + # quant node. + with graph.inserting_before(node): + quant_node = graph.call_function(node.target, args=tuple(quant_args)) + quant_node.meta = node.meta + # Move the trivially quantizable node after the quant node + with graph.inserting_after(node): + tq_args = list(trivially_quantizable_op.args) + tq_args[0] = quant_node + tq_node = graph.call_function( + trivially_quantizable_op.target, + args=tuple(tq_args), + kwargs=trivially_quantizable_op.kwargs, + ) + tq_node.meta = trivially_quantizable_op.meta + # Replace all uses of node with newly created tq_node + node.replace_all_uses_with(tq_node) + # We can safely remove the quant node and trivially quantizable op + graph.erase_node(node) + graph.erase_node(trivially_quantizable_op) + modified = True + + return modified + + def call(self, graph_module: torch.fx.GraphModule) -> PassResult: + self.graph_module = graph_module + modified = self.advance_quantize_op(graph_module) + if modified: + graph_module.recompile() + graph_module.graph.eliminate_dead_code() + return super().call(graph_module) + + return PassResult(graph_module, False) diff --git a/backends/transforms/advance_quantize_op_above_def_in_branch.py b/backends/transforms/advance_quantize_op_above_def_in_branch.py new file mode 100644 index 00000000000..18a6ee3f3d7 --- /dev/null +++ b/backends/transforms/advance_quantize_op_above_def_in_branch.py @@ -0,0 +1,196 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# pyre-unsafe + +from math import prod +from typing import List + +import torch +import torch.fx +from executorch.backends.transforms.permute_pass_utils import ( + get_overload_packet, + get_placeholders, + get_shape, +) +from executorch.backends.transforms.quantize_reorder_utils import ( + slice_or_select_overloadpkt, + trivially_quantizable_ops_overloadpkt, +) +from executorch.exir.dialects._ops import ops as exir_ops +from executorch.exir.pass_base import ExportPass, PassResult + + +class AdvanceQuantizeOpAboveDefInBranchPass(ExportPass): + """ + If the graph is branched with the following pattern: + I = ... + S1 = slice(I) + Q1 = quantize(S1) + S2 = slice(I) + Q2 = quantize(S2) + S3 = slice(I) + Q3 = quantize(S3) + ... + such that the elements in the slices S1 + S2 + S3 is greater than I, + we can advance the quantize above their defs (i.e., all the slice nodes), + and reorder the pattern to the following: + I = ... + Q1 = quantize(I) + S1 = slice(Q1) + Q1 = requantize(S1) + S2 = slice(Q1) + Q2 = requantize(S2) + S3 = slice(Q1) + Q3 = requantize(S3) + ... + Note that the other passes won't do this transformation because they expect + a linear chain of def-use, which is not true here; the uses of I are + branched. + """ + + quantize_op_packets: set[object] = { + torch.ops.quantized_decomposed.quantize_per_tensor, + exir_ops.edge.quantized_decomposed.quantize_per_tensor, + } + + def __init__(self): + super().__init__() + self.graph_module = None + + # Starting at node, iterate through its successors, bypassing any trivially + # quantizable op. If all the descendents are quantize ops, return them. + def get_descendent_quant_ops(self, node: torch.fx.Node) -> List[torch.fx.Node]: + # The list of quant ops that are descendents of node, such that the only + # nodes in the path from node --> quant are trivially quantizable ops. + descendent_quant_ops = [] + # The list of trivially quantizable ops in the path from node --> quant op. + trivial_quantized_ops = [] + + users = list(node.users.keys()) + while users: + user = users.pop(0) + user_target = get_overload_packet(user.target) + # Record a quant op successor + if user_target in self.quantize_op_packets: + descendent_quant_ops.append(user) + # If the successor is a trivially quantizable op, consider its users + # instead. + elif user_target in trivially_quantizable_ops_overloadpkt: + trivial_quantized_ops.append(user) + users.extend(list(user.users.keys())) + # Otherwise all successors of node are not quant op, so break the loop. + else: + descendent_quant_ops.clear() + break + + # If all the nodes in trivial_quantize_ops of the node were slice ops, + # ensure that the advance is still profitable. + if descendent_quant_ops and all( + get_overload_packet(x.target) in slice_or_select_overloadpkt + for x in trivial_quantized_ops + ): + # Profitability metric: the sum of all the output slices must be + # more than half the input node size. + slice_sizes = [ + prod(list(y)) + for x in trivial_quantized_ops + if (y := get_shape(self.graph_module, x)) is not None + ] + node_shape = get_shape(self.graph_module, node) + node_size = prod(list(node_shape)) if node_shape is not None else 0 + if node_size >= 2 * sum(slice_sizes): + descendent_quant_ops.clear() + + return descendent_quant_ops + + def advance_quantize_op(self, graph_module: torch.fx.GraphModule): + graph = graph_module.graph + for node in graph.nodes: + # We are only interested in call functions and placeholders + if node.op not in {"placeholder", "call_function"}: + continue + # If the node is trivially quantizable, skip it + if ( + get_overload_packet(node.target) + in trivially_quantizable_ops_overloadpkt + ): + continue + # Get the descendent quant ops that are connected to the current + # node via trivially quantizable ops. + descendent_quant_ops = self.get_descendent_quant_ops(node) + if not descendent_quant_ops: + continue + + # Get the insertion point below which we need to insert anything. + # if node is a placeholder, we will only insert a new node after + # all the placeholders in the graph. + insertion_pt = ( + get_placeholders(graph)[-1] if node.op == "placeholder" else node + ) + + # If the node only has a single quant op as descendent, we can + # simply hoist the quant op below the current node as its single + # child. + if len(descendent_quant_ops) == 1: + quant_node = descendent_quant_ops.pop() + # Replace the uses of quant node with its predecessor + quant_node.replace_all_uses_with(quant_node.args[0]) # pyre-fixme[6] + # Hoist the quant node after the current node. Make sure that + # the insertion is after placeholders + with graph.inserting_after(insertion_pt): + dom_quant_args = (node,) + quant_node.args[1:] + dom_quant_node = graph.call_function( + exir_ops.edge.quantized_decomposed.quantize_per_tensor.default + ) + dom_quant_node.meta = node.meta + node.replace_all_uses_with(dom_quant_node) + dom_quant_node.args = dom_quant_args + graph.erase_node(quant_node) + continue + + # Otherwise we have multiple quant descendents. Requantizing them from + # one hoisted quantize is only lossless if they all share qparams, so + # skip when they differ. + quant_args = { + tuple(quant_node.args[1:]) for quant_node in descendent_quant_ops + } + if len(quant_args) > 1: + continue + (rep_args,) = quant_args + + # Create a new quant node that dominates all the nodes in + # descendent_quant_ops. Make sure that the insertion is after + # all the placeholders. + with graph.inserting_after(insertion_pt): + dom_quant_args = (node,) + rep_args + dom_quant_node = graph.call_function( + exir_ops.edge.quantized_decomposed.quantize_per_tensor.default + ) + dom_quant_node.meta = node.meta + node.replace_all_uses_with(dom_quant_node) + dom_quant_node.args = dom_quant_args + + # Finally, convert each of the quant node to a dequant/quant pair that + # requantizes the data flowing through dom_quant_node. + # TODO: Once requantize is implemented for PT2, replace the + # dequant/quant pair here with a single requantize node + for quant_node in descendent_quant_ops: + with graph.inserting_before(quant_node): + dequant_node = graph.call_function( + exir_ops.edge.quantized_decomposed.dequantize_per_tensor.default + ) + dequant_node.args = (quant_node.args[0],) + rep_args + quant_node.args = (dequant_node,) + quant_node.args[1:] + + graph_module.recompile() + graph_module.graph.eliminate_dead_code() + + def call(self, graph_module: torch.fx.GraphModule) -> PassResult: + self.graph_module = graph_module + self.advance_quantize_op(graph_module) + result = super().call(graph_module) + return result diff --git a/backends/transforms/fuse_quant_dequant_to_requantize.py b/backends/transforms/fuse_quant_dequant_to_requantize.py new file mode 100644 index 00000000000..722afab71b4 --- /dev/null +++ b/backends/transforms/fuse_quant_dequant_to_requantize.py @@ -0,0 +1,240 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# pyre-unsafe + +from typing import cast, Optional + +import torch +import torch.fx +from executorch.backends.transforms.permute_pass_utils import ( + FuseOpPairsAcrossBranchesPass, + get_arg, +) +from executorch.exir.dialects._ops import ops as exir_ops +from executorch.exir.dialects.edge._ops import EdgeOpOverload, EdgeOpOverloadPacket +from executorch.exir.pass_base import PassResult +from torch.utils import _pytree as pytree + + +class FuseQuantDequantToRequantizePass(FuseOpPairsAcrossBranchesPass): + """ + Fuse dequantize-quantize op pairs to a single requantize op. + For the special case where quant params match, this will remove + both dequant and quant ops. + """ + + # A list of ops that can be bypassed when looking for a + # dequantize->quantize chain + bypass_ops: set[EdgeOpOverload] = { + exir_ops.edge.aten.slice_copy.Tensor, + exir_ops.edge.aten.view_copy.default, + exir_ops.edge.aten.clone.default, + exir_ops.edge.aten.transpose_copy.int, + exir_ops.edge.aten.permute_copy.default, + } + + quantize_op_packets: set[EdgeOpOverloadPacket] = { + exir_ops.edge.quantized_decomposed.quantize_per_tensor, + } + dequantize_op_packets: set[EdgeOpOverloadPacket] = { + exir_ops.edge.quantized_decomposed.dequantize_per_tensor, + } + # Op used to fuse a dequantize-quantize pair whose qparams differ. There is + # no backend-neutral requantize op, so a backend that wants such pairs fused + # must set this; otherwise only pairs with matching qparams are removed. + requantize_op: Optional[EdgeOpOverload] = None + + def __init__( + self, allow_requantize: bool = False, force_quant_dequant_fusion: bool = False + ) -> None: + super().__init__() + if allow_requantize and self.requantize_op is None: + raise ValueError( + f"{type(self).__name__} has no requantize_op, so it cannot " + "run with allow_requantize=True" + ) + self.allow_requantize: bool = allow_requantize + self.force_quant_dequant_fusion: bool = force_quant_dequant_fusion + + def _pkg_name_match(self, node1: torch.fx.Node, node2: torch.fx.Node) -> bool: + # pyre-ignore[16]: Item `typing.Callable` has no attribute `_op` + return node1.target._op.namespace == node2.target._op.namespace + + def can_fuse_for_chain( + self, + producer: torch.fx.Node, + consumer: torch.fx.Node, + consumer_op_packets: set[EdgeOpOverloadPacket], + ) -> bool: + return super().can_fuse_for_chain( + producer, consumer, consumer_op_packets + ) and self._pkg_name_match(producer, consumer) + + def _create_requantize_node( + self, + in_tensor: torch.fx.Node, + in_scale: float, + in_zero_point: int, + out_scale: float, + out_zero_point: int, + out_dtype: torch.dtype, + graph: torch.fx.Graph, + ) -> torch.fx.Node: + return graph.call_function( + cast(EdgeOpOverload, self.requantize_op), + args=( + in_tensor, + in_scale, + in_zero_point, + out_scale, + out_zero_point, + out_dtype, + ), + ) + + def _quant_params_match(self, node1: torch.fx.Node, node2: torch.fx.Node) -> bool: + arg_names = ("scale", "zero_point", "quant_min", "quant_max", "dtype") + arg_types = (float, int, int, int, torch.dtype) + return all( + get_arg(node1, name, arg_type) == get_arg(node2, name, arg_type) + for name, arg_type in zip(arg_names, arg_types) + ) + + def check_ok_to_fuse( + self, + producer: torch.fx.Node, + consumers: list[torch.fx.Node], + ) -> bool: + """Check if all node-user pairs are nops or are ok to replace with requant.""" + for rnode in consumers: + if self.allow_requantize or self._quant_params_match(producer, rnode): + # Cannot remove quant-dequant pair if quant params don't match and requantize + # is not allowed. + continue + return False + return True + + def _get_bypassed_nodes( + self, + producer: torch.fx.Node, + removal_candidates: list[torch.fx.Node], + ) -> list[torch.fx.Node]: + """Return the bypassed ops between producer and consumers, in order.""" + candidates = set(removal_candidates) + bypassed: set[torch.fx.Node] = set() + stack = list(producer.users) + while stack: + user = stack.pop() + if user in candidates or user in bypassed: + continue + bypassed.add(user) + stack.extend(user.users) + + # The graph's node list is topologically sorted, so walking it forward + # from the producer refreshes each node after its predecessor. + ordered: list[torch.fx.Node] = [] + cursor = producer.next + while bypassed and cursor.op != "root": + if cursor in bypassed: + bypassed.remove(cursor) + ordered.append(cursor) + cursor = cursor.next + return ordered + + def fuse( + self, + node: torch.fx.Node, + removal_candidates: list[torch.fx.Node], + graph_module: torch.fx.GraphModule, + ) -> None: + # Capture the bypassed ops before the rewire erases the chain's root. + bypassed_nodes = self._get_bypassed_nodes(node, removal_candidates) + node.replace_all_uses_with(cast(torch.fx.Node, node.args[0])) + graph_module.graph.erase_node(node) + # They now consume the producer's input, so their metadata describes an + # operand they no longer have. Refresh before get_fused_node reads it. + for bypassed in bypassed_nodes: + args, kwargs = pytree.tree_map_only( + torch.fx.Node, + lambda arg: arg.meta["val"], + (bypassed.args, bypassed.kwargs), + ) + assert callable(bypassed.target) + bypassed.meta["val"] = bypassed.target(*args, **kwargs) + bypassed.meta["tensor_meta"] = None + for rnode in removal_candidates: + rnode.replace_all_uses_with(self.get_fused_node(node, rnode, graph_module)) + graph_module.graph.erase_node(rnode) + + def get_fused_node( + self, + producer: torch.fx.Node, + consumer: torch.fx.Node, + graph_module: torch.fx.GraphModule, + ) -> torch.fx.Node: + in_scale = get_arg(producer, "scale", float) + in_zero_point = get_arg(producer, "zero_point", int) + out_scale = get_arg(consumer, "scale", float) + out_zero_point = get_arg(consumer, "zero_point", int) + out_dtype = get_arg(consumer, "dtype", torch.dtype) + if in_scale == out_scale and in_zero_point == out_zero_point: + # If the quant params match, we can remove both dequantize-quantize ops. + return cast(torch.fx.Node, consumer.args[0]) + + assert ( + self.allow_requantize + ), f"Found {producer=} {in_scale=} {in_zero_point=} | {consumer=} {out_scale=} {out_zero_point=}" + + with graph_module.graph.inserting_before(consumer): + requantize_node = self._create_requantize_node( + in_tensor=cast(torch.fx.Node, consumer.args[0]), + in_scale=in_scale, + in_zero_point=in_zero_point, + out_scale=out_scale, + out_zero_point=out_zero_point, + out_dtype=out_dtype, + graph=graph_module.graph, + ) + requantize_node.meta = consumer.meta.copy() + requantize_node.meta["val"] = cast(EdgeOpOverload, self.requantize_op)( + cast(torch.fx.Node, consumer.args[0]).meta["val"], + cast(float, in_scale), + cast(int, in_zero_point), + cast(float, out_scale), + cast(int, out_zero_point), + cast(torch.dtype, out_dtype), + ) + requantize_node.meta["tensor_meta"] = None + return requantize_node + + def call(self, graph_module: torch.fx.GraphModule) -> PassResult: + # Remove any dequantize op that has only quantize ops as its users. + modified = self.find_and_fuse( + graph_module, + producer_op_packets=self.dequantize_op_packets, + consumer_op_packets=self.quantize_op_packets, + bypass_ops=self.bypass_ops, + ) + # Remove any quantize op that has only dequantze ops as its users. + modified |= self.find_and_fuse( + graph_module, + producer_op_packets=self.quantize_op_packets, + consumer_op_packets=self.dequantize_op_packets, + # Do not requantize for quantize-dequantize pairs as this is not guaranteed + # to be better for performance/memory. + # Only fuse if all users of quant are dequant. + bypass_ops=( + self.bypass_ops + if self.force_quant_dequant_fusion + else {exir_ops.edge.aten.view_copy.default} + ), + ) + if modified: + graph_module.graph.eliminate_dead_code() + graph_module.recompile() + return PassResult(graph_module, True) + return PassResult(graph_module, False) diff --git a/backends/transforms/move_permute_after_concat.py b/backends/transforms/move_permute_after_concat.py new file mode 100644 index 00000000000..a08bc91442a --- /dev/null +++ b/backends/transforms/move_permute_after_concat.py @@ -0,0 +1,109 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# pyre-unsafe + +from typing import cast, List, Optional, Tuple + +import torch +import torch.fx +from executorch.backends.transforms.permute_pass_utils import ( + get_arg, + RemoveOrReplacePassInterface, +) +from executorch.exir.dialects._ops import ops as exir_ops +from executorch.exir.dialects.edge._ops import EdgeOpOverload + + +class MovePermuteAfterConcat(RemoveOrReplacePassInterface): + """Move matching single-use permutes after a cat. + + For example, + + cat( + [ + permute(x[80, 16, 32], [1, 2, 0]), # [16, 32, 80] + permute(y[1, 16, 32], [1, 2, 0]), # [16, 32, 1] + ], + dim=2, + ) # [16, 32, 81] + + becomes + + permute(cat([x, y], dim=0), [1, 2, 0]) + # cat: [81, 16, 32], output: [16, 32, 81] + """ + + @property + def targets(self) -> list[EdgeOpOverload]: + return [exir_ops.edge.aten.cat.default] + + @staticmethod + def _match_inputs( + cat_node: torch.fx.Node, + ) -> Optional[Tuple[List[torch.fx.Node], Tuple[int, ...]]]: + inputs = get_arg( + cat_node, + "tensors", + # pyre-ignore[6] + list[torch.fx.Node] | tuple[torch.fx.Node, ...], + ) + # Moving one reused permute after cat makes it process the larger output. + if inputs and all(inp is inputs[0] for inp in inputs): + return None + + unpermuted_inputs: List[torch.fx.Node] = [] + common_permutation: Optional[Tuple[int, ...]] = None + for permute_node in inputs: + if ( + permute_node.target != exir_ops.edge.aten.permute_copy.default + or len(permute_node.users) != 1 + ): + return None + + permute_input = cast(torch.fx.Node, permute_node.args[0]) + dims = get_arg( + permute_node, + "dims", + # pyre-ignore[6] + list[int] | tuple[int, ...], + ) + rank = len(dims) + permutation = tuple(dim % rank for dim in dims) + if common_permutation is None: + common_permutation = permutation + elif permutation != common_permutation: + return None + unpermuted_inputs.append(permute_input) + + return unpermuted_inputs, cast(Tuple[int, ...], common_permutation) + + def maybe_remove_or_replace(self, node: torch.fx.Node) -> bool: + match = self._match_inputs(node) + if match is None: + return False + inputs, permutation = match + + output_cat_dim = get_arg(node, "dim", int) % len(permutation) + input_cat_dim = permutation[output_cat_dim] + graph = node.graph + + with graph.inserting_before(node): + new_cat = graph.call_function( + exir_ops.edge.aten.cat.default, + args=(inputs, input_cat_dim), + ) + new_cat.meta["val"] = exir_ops.edge.aten.cat.default( + [inp.meta["val"] for inp in inputs], input_cat_dim + ) + new_permute = graph.call_function( + exir_ops.edge.aten.permute_copy.default, + args=(new_cat, list(permutation)), + ) + new_permute.meta = node.meta.copy() + + node.replace_all_uses_with(new_permute) + return True diff --git a/backends/transforms/move_slice_before_permute.py b/backends/transforms/move_slice_before_permute.py new file mode 100644 index 00000000000..888c00c5638 --- /dev/null +++ b/backends/transforms/move_slice_before_permute.py @@ -0,0 +1,95 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# pyre-unsafe + +from math import prod +from typing import cast + +import torch +import torch.fx +from executorch.backends.transforms.permute_pass_utils import ( + get_arg, + RemoveOrReplacePassInterface, +) +from executorch.exir.dialects._ops import ops as exir_ops +from executorch.exir.dialects.edge._ops import EdgeOpOverload + + +class MoveSliceBeforePermutePass(RemoveOrReplacePassInterface): + """Move slice_copy ops before permute_copy to reduce permute data volume. + + Rewrites permute(input, perm) -> slice(dim=D) into + slice(input, dim=perm[D]) -> permute(sliced, perm), so the permute + operates on a smaller tensor. + + Scans slice nodes and matches upstream permutes. This also handles + chained cases (permute -> slice -> slice) in one pass: each slice + independently checks its input for a permute. + + Cost model: dim-0 slices are nop-eligible (zero-copy pointer offset + after MakeSliceAndCatDimOutermostPass). Moving such a slice loses the + nop, so we only move it when the permute savings outweigh the nop loss, + i.e. when the slice removes more than half the data (full > 2 * sliced). + Non-dim-0 slices have no nop opportunity, so any permute savings is + pure win. + """ + + STRIDED_SLICE_COST_FACTOR: int = 2 + + @property + def targets(self) -> list[EdgeOpOverload]: + return [exir_ops.edge.aten.slice_copy.Tensor] + + def maybe_remove_or_replace(self, node: torch.fx.Node) -> bool: + permute_node = get_arg(node, "input", torch.fx.Node) + if permute_node.target != exir_ops.edge.aten.permute_copy.default: + return False + + if len(permute_node.users) != 1: + return False + + perm = cast(list[int], permute_node.args[1]) + permute_input = permute_node.args[0] + assert isinstance(permute_input, torch.fx.Node) + + slice_dim = get_arg(node, "dim", int) + + full_size = prod(permute_node.meta["val"].shape) + sliced_size = prod(node.meta["val"].shape) + if slice_dim == 0 and full_size <= self.STRIDED_SLICE_COST_FACTOR * sliced_size: + return False + + new_dim = perm[slice_dim] + graph = node.graph + + with graph.inserting_before(permute_node): + new_slice_args = ( + permute_input, + new_dim, + get_arg(node, "start"), + get_arg(node, "end"), + get_arg(node, "step", int), + ) + new_slice = graph.create_node( + "call_function", + exir_ops.edge.aten.slice_copy.Tensor, + args=new_slice_args, + ) + new_slice.meta["val"] = exir_ops.edge.aten.slice_copy.Tensor( + permute_input.meta["val"], *new_slice_args[1:] + ) + new_permute = graph.create_node( + "call_function", + exir_ops.edge.aten.permute_copy.default, + args=(new_slice, perm), + ) + new_permute.meta["val"] = exir_ops.edge.aten.permute_copy.default( + new_slice.meta["val"], perm + ) + + node.replace_all_uses_with(new_permute) + return True diff --git a/backends/transforms/move_slice_before_view.py b/backends/transforms/move_slice_before_view.py new file mode 100644 index 00000000000..c8f4dc823d7 --- /dev/null +++ b/backends/transforms/move_slice_before_view.py @@ -0,0 +1,251 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# pyre-unsafe + +from math import prod +from typing import Optional + +import torch +import torch.fx +from executorch.backends.transforms.permute_pass_utils import ( + get_arg, + RemoveOrReplacePassInterface, +) +from executorch.exir.dialects._ops import ops as exir_ops +from executorch.exir.dialects.edge._ops import EdgeOpOverload + + +class MoveSliceBeforeViewPass(RemoveOrReplacePassInterface): + """Move a slice_copy above a view_copy when the slice is re-expressible as a + single slice on one dim of the pre-view tensor. + + Rewrites view(x) -> slice(dim=d, start, end, step) into + slice(x, dim=d', start', end', step') -> view(sliced, slice_out_shape), so the + slice lands directly on x. This may be useful in attention patterns, where + we view outputs of a large linear into a new shape where the number of + attention heads are the last dim, and we need to run independent computation + per head. Moving the slice before the view can allow us to then directly slice + the constant linear weights. + + A view is a contiguous reshape: it never moves or reorders elements, it only + re-groups the shared row-major index space into different dims. A slice keeps + an arithmetic progression of indices (start, start+step, ...) along one viewed + dim, and that progression collapses back to a *single* slice on one pre-view + dim exactly when the row-major strides line up. ``_derive_pre_view_slice`` + handles the three cases that qualify: + + * untouched dim: the viewed dim is left unchanged by the view -- same size + and same inner stride as some pre-view dim -- so the slice copies over + verbatim (any step). + * contiguous: the viewed dim and a pre-view dim span the same flat extent + (a split's outermost factor, or a merge that aligns), so a contiguous + (step==1) slice maps to a contiguous pre-view slice. + * strided: the viewed dim is an innermost factor of a pre-view dim + (identical inner stride) selected width-1, so it maps to a strided + pre-view slice with step == the viewed dim's size. + + Everything else -- middle factors, wider strided selections -- is block-strided + (runs separated by gaps), which no single slice can express, so it is left + unchanged. + + Each slice is handled independently, so a view that fans out to several slices + is rewritten one slice at a time and the now-dead view is removed by dead-code + elimination -- there is no single-user requirement on the view. + """ + + @property + def targets(self) -> list[EdgeOpOverload]: + return [exir_ops.edge.aten.slice_copy.Tensor] + + def maybe_remove_or_replace(self, node: torch.fx.Node) -> bool: + view_node = get_arg(node, "input", torch.fx.Node) + if view_node.target != exir_ops.edge.aten.view_copy.default: + return False + + x_node = get_arg(view_node, "input", torch.fx.Node) + pre_view_shape = tuple(x_node.meta["val"].shape) + post_view_shape = tuple(view_node.meta["val"].shape) + if 0 in pre_view_shape or 0 in post_view_shape: + return False + + dim = get_arg(node, "dim", int) + if dim < 0: + dim += len(post_view_shape) + post_view_size = post_view_shape[dim] + + bounds = self._normalize_slice(node, post_view_size) + if bounds is None: + return False + start, stop, step = bounds + + # The slice's own output shape gives the selected-element count along the + # sliced dim directly -- it is exactly output_shape[dim]. + slice_out_shape = tuple(node.meta["val"].shape) + post_view_count = slice_out_shape[dim] + if post_view_count == 0: + return False + + # Row-major stride of the sliced viewed dim, and of every pre-view dim. + post_view_stride = prod(post_view_shape[dim + 1 :]) + pre_view_strides = self._row_major_strides(pre_view_shape) + + derived = self._derive_pre_view_slice( + pre_view_shape, + pre_view_strides, + post_view_stride, + post_view_size, + start, + stop, + step, + post_view_count, + ) + if derived is None: + return False + pre_view_dim, pre_view_start, pre_view_stop, pre_view_step = derived + + graph = node.graph + with graph.inserting_before(node): + new_slice_args = ( + x_node, + pre_view_dim, + pre_view_start, + pre_view_stop, + pre_view_step, + ) + new_slice = graph.create_node( + "call_function", + exir_ops.edge.aten.slice_copy.Tensor, + args=new_slice_args, + ) + new_slice.meta["val"] = exir_ops.edge.aten.slice_copy.Tensor( + x_node.meta["val"], *new_slice_args[1:] + ) + new_view = graph.create_node( + "call_function", + exir_ops.edge.aten.view_copy.default, + args=(new_slice, list(slice_out_shape)), + ) + new_view.meta["val"] = exir_ops.edge.aten.view_copy.default( + new_slice.meta["val"], list(slice_out_shape) + ) + + node.replace_all_uses_with(new_view) + return True + + @staticmethod + def _row_major_strides(shape: tuple[int, ...]) -> list[int]: + """Row-major (contiguous) strides for ``shape``.""" + strides = [1] * len(shape) + acc = 1 + for i in range(len(shape) - 1, -1, -1): + strides[i] = acc + acc *= shape[i] + return strides + + def _normalize_slice( + self, node: torch.fx.Node, post_view_size: int + ) -> Optional[tuple[int, int, int]]: + """Resolve the slice to concrete, clamped ``(start, stop, step)`` ints, or + None if the bounds are dynamic or the step is non-positive (neither of + which this pass handles).""" + step = get_arg(node, "step") + + if not isinstance(step, int): + return None + + if step <= 0: + return None + + raw_start = get_arg(node, "start") + raw_stop = get_arg(node, "end") + + # Make sure raw_start/raw_stop are not symbolic. + if (raw_start is not None and not isinstance(raw_start, int)) or ( + raw_stop is not None and not isinstance(raw_stop, int) + ): + return None + + start = 0 if raw_start is None else raw_start + stop = post_view_size if raw_stop is None else raw_stop + if start < 0: + start += post_view_size + if stop < 0: + stop += post_view_size + start = max(0, min(start, post_view_size)) + stop = max(0, min(stop, post_view_size)) + return start, stop, step + + def _derive_pre_view_slice( + self, + pre_view_shape: tuple[int, ...], + pre_view_strides: list[int], + post_view_stride: int, + post_view_size: int, + start: int, + stop: int, + step: int, + post_view_count: int, + ) -> tuple[int, int, int, int] | None: + """Return ``(dim, start, stop, step)`` for the single pre-view-tensor slice + equivalent to slicing the viewed dim, or None if no single pre-view slice + reproduces it. + + Both shapes index the same row-major flat space, so the sliced viewed dim + (size ``post_view_size``, inner stride ``post_view_stride``) lines up with + one pre-view dim (size ``pre_view_size``, inner stride ``pre_view_stride``) + in one of three ways. + """ + for pre_view_dim, (pre_view_stride, pre_view_size) in enumerate( + zip(pre_view_strides, pre_view_shape) + ): + # Untouched: the viewed dim is identical to this pre-view dim (same + # size and same inner stride), so the slice applies verbatim, any step. + if pre_view_stride == post_view_stride and pre_view_size == post_view_size: + return pre_view_dim, start, stop, step + + # Contiguous: the viewed dim and this pre-view dim span the same flat + # extent (same period), and the selected band aligns to this dim's + # boundaries. A contiguous (step==1) viewed slice + # [start, start+post_view_count) is the flat band [start* + # post_view_stride, (start+post_view_count)*post_view_stride), a + # contiguous slice on this pre-view dim iff both ends are multiples of + # its stride. + if ( + step == 1 + and post_view_size * post_view_stride == pre_view_size * pre_view_stride + ): + flat_start = start * post_view_stride + flat_stop = (start + post_view_count) * post_view_stride + if ( + flat_start % pre_view_stride == 0 + and flat_stop % pre_view_stride == 0 + ): + return ( + pre_view_dim, + flat_start // pre_view_stride, + flat_stop // pre_view_stride, + 1, + ) + + # Strided is the ONLY way the reshape itself introduces a stride, and + # it requires a width-1 selection (post_view_count == 1): the viewed + # dim is an innermost factor of this pre-view dim (identical inner + # stride), so fixing that single factor index and letting the rest of + # the pre-view dim run yields a uniform stride equal to the viewed dim's + # size. Any wider selection (post_view_count > 1) of an inner factor + # leaves runs separated by gaps -- block-strided, not a single slice -- + # so width-1 is required. + if ( + post_view_count == 1 + and post_view_size > 1 + and pre_view_stride == post_view_stride + and pre_view_size % post_view_size == 0 + ): + pre_view_count = pre_view_size // post_view_size + pre_view_stop = start + (pre_view_count - 1) * post_view_size + 1 + return pre_view_dim, start, pre_view_stop, post_view_size + return None diff --git a/backends/transforms/permute_pass_utils.py b/backends/transforms/permute_pass_utils.py index a4a0f7c7a5b..a3ad1406a0d 100644 --- a/backends/transforms/permute_pass_utils.py +++ b/backends/transforms/permute_pass_utils.py @@ -14,7 +14,7 @@ from abc import abstractmethod from collections import deque -from typing import cast, List, Optional, Type, TypeVar, Union +from typing import Callable, cast, List, Optional, Type, TypeVar, Union import torch import torch.fx @@ -22,12 +22,27 @@ from executorch.exir.dialects._ops import ops as exir_ops from executorch.exir.dialects.edge._ops import EdgeOpOverload, EdgeOpOverloadPacket from executorch.exir.pass_base import ExportPass, PassResult +from torch._ops import OpOverloadPacket from torch.fx import Node from torch.fx.node import Argument T = TypeVar("T") +def get_overload_packet( + op: Union[Callable[..., str], str], +) -> Union[OpOverloadPacket, EdgeOpOverloadPacket, None]: + return ( + get_edge_overload_packet(op) + if isinstance(op, EdgeOpOverload) + else getattr(op, "overloadpacket", None) + ) + + +def get_placeholders(graph: torch.fx.Graph) -> List[torch.fx.Node]: + return list(filter(lambda x: x.op == "placeholder", graph.nodes)) + + def get_edge_overload_packet(edge_op: EdgeOpOverload) -> EdgeOpOverloadPacket: edge_op_namespace, edge_op_name = ( edge_op.namespace, diff --git a/backends/transforms/postpone_dequantize_op_below_use_chain.py b/backends/transforms/postpone_dequantize_op_below_use_chain.py new file mode 100644 index 00000000000..93fb4d1f28a --- /dev/null +++ b/backends/transforms/postpone_dequantize_op_below_use_chain.py @@ -0,0 +1,165 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# pyre-unsafe + +from math import prod + +import torch +import torch.fx +from executorch.backends.transforms.permute_pass_utils import ( + get_overload_packet, + get_shape, +) +from executorch.backends.transforms.quantize_reorder_utils import ( + slice_or_select_overloadpkt, + trivially_quantizable_ops_overloadpkt, +) +from executorch.exir.dialects._ops import ops as exir_ops +from executorch.exir.pass_base import ExportPass, PassResult +from executorch.exir.tensor import num_bytes_from_shape_and_dtype + + +class PostponeDequantizeOpBelowUseChainPass(ExportPass): + """ + If the consumer of dequantize is a linear chain of view, transpose, permute, + or slice ops that are trivially quantized, we can convert the pattern + dequantize(int8/uint8) -> view/transpose/permute/slice(fp32) to + view/transpose/permute/slice(int8/uint8) -> dequantize(int8/uint8) + The benefit of such reordering is that the view/transpose/permute/slice + will move far less data. + """ + + quantize_op_packets: set[object] = { + exir_ops.edge.quantized_decomposed.quantize_per_tensor, + exir_ops.edge.quantized_decomposed.quantize_per_channel, + } + dequantize_packet_to_overload: dict[object, str] = { + exir_ops.edge.quantized_decomposed.dequantize_per_tensor: "default", + exir_ops.edge.quantized_decomposed.dequantize_per_channel: "default", + } + + def __init__(self): + super().__init__() + self.graph_module = None + + # Return true if postponing the dequantize node is feasible + def postponing_feasible(self, dequant_node: torch.fx.Node): + users = list(dequant_node.users.keys()) + # Check if the dequantize op has a single user, and that user is + # trivially quantizable. + trivially_quantizable_users = all( + get_overload_packet(user.target) in trivially_quantizable_ops_overloadpkt + for user in users + ) + if len(users) == 1: + return trivially_quantizable_users + + # Otherwise check if all the users are slice op + if not all( + get_overload_packet(user.target) in slice_or_select_overloadpkt + for user in users + ): + return False + + quantized_branches = [] + for user in users: + slice_users = list(user.users) + quantized_branches.append( + bool(slice_users) + and all( + slice_user.op == "call_function" + and get_overload_packet(slice_user.target) + in self.quantize_op_packets + for slice_user in slice_users + ) + ) + + # Preserve the existing fallback for forks whose branches all requantize. + if all(quantized_branches): + return True + + dequant_shape = get_shape(self.graph_module, dequant_node) + if dequant_shape is None: + return False + + dequant_bytes = num_bytes_from_shape_and_dtype(dequant_shape, torch.float32) + surviving_dequant_bytes = 0 + for user, quantized_branch in zip(users, quantized_branches): + if quantized_branch: + continue + slice_shape = get_shape(self.graph_module, user) + if slice_shape is None: + return False + # Nop slices are removed later and do not add another materialized copy. + if prod(list(slice_shape)) == prod(list(dequant_shape)): + continue + surviving_dequant_bytes += num_bytes_from_shape_and_dtype( + slice_shape, torch.float32 + ) + + return surviving_dequant_bytes <= dequant_bytes + + def postpone_dequantize_op(self, graph_module: torch.fx.GraphModule) -> bool: + packet_to_overload_map = self.dequantize_packet_to_overload + graph = graph_module.graph + modified = False + for node in graph.nodes: + overload_packet = get_overload_packet(node.target) + if ( + overload_packet not in packet_to_overload_map.keys() + or not self.postponing_feasible(node) + ): + continue + + for user in node.users: + with graph.inserting_after(user): + dequant_node = graph.call_function( + getattr( + overload_packet, packet_to_overload_map[overload_packet] + ), + args=(user, *node.args[1:]), + ) + dequant_node.meta = user.meta.copy() + # Remove meta["debug_handle"] on new node if it exists. + # Reassign it at the caller level by calling generate_missing_debug_handles + dequant_node.meta.pop("debug_handle", None) + user.replace_all_uses_with(dequant_node) + dequant_node.args = (user, *node.args[1:]) + + pred = node.args[0] + node.replace_all_uses_with(pred) + graph.erase_node(node) + modified = True + + if modified: + graph_module.recompile() + return modified + + def call(self, graph_module: torch.fx.GraphModule) -> PassResult: + # The logic in postpone_dequantize_op that handles branching checks the shape + # of the dequant node, which isn't available if that node was already postponed + # in the same pass invokation. The shape information is recreated by tracing in + # super().call(), meaning that every branch in the graph that we wish to postpone + # dequant past requires retracing. We iterate the pass until it no longer modifies + # the graph (up to 3 times max, to avoid potential infinite loops) + self.graph_module = graph_module + iter_count = 0 + local_modified = False + overall_modified = False + + while local_modified or iter_count == 0: + local_modified = self.postpone_dequantize_op(self.graph_module) + overall_modified |= local_modified + + if local_modified: + self.graph_module = super().call(self.graph_module).graph_module + + iter_count += 1 + if iter_count == 3: + break + + return PassResult(self.graph_module, overall_modified) diff --git a/backends/transforms/propagate_slice.py b/backends/transforms/propagate_slice.py new file mode 100644 index 00000000000..e1733553c97 --- /dev/null +++ b/backends/transforms/propagate_slice.py @@ -0,0 +1,222 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# pyre-unsafe + +from math import prod +from typing import Callable, cast, Optional + +import torch +import torch.fx +from executorch.backends.transforms.permute_pass_utils import ( + get_arg, + RemoveOrReplacePassInterface, +) +from executorch.exir.dialects._ops import ops as exir_ops +from executorch.exir.dialects.edge._ops import EdgeOpOverload + + +class PropagateSlice(RemoveOrReplacePassInterface): + """Propagate slice_copy before element-wise ops when the cost model + indicates it reduces total data movement. + + Supported ops (extensible via dispatch table): + - quantize_per_tensor: unary element-wise + - dequantize_per_tensor: unary element-wise + - add.Tensor: binary with broadcast — slices non-broadcasting inputs + - mul.Tensor: binary with broadcast — slices non-broadcasting inputs + + Callers can provide per-target filters for additional legality constraints. + Handles any slice dim and any step size. + """ + + quant_unary_targets: list[EdgeOpOverload] = [ + exir_ops.edge.quantized_decomposed.quantize_per_tensor.default, + exir_ops.edge.quantized_decomposed.dequantize_per_tensor.default, + ] + + def __init__( + self, + additional_unary_targets: Optional[list[EdgeOpOverload]] = None, + additional_binary_targets: Optional[list[EdgeOpOverload]] = None, + target_filters: Optional[ + dict[EdgeOpOverload, Callable[[torch.fx.Node], bool]] + ] = None, + ) -> None: + super().__init__() + unary_targets = [ + *self.quant_unary_targets, + *(additional_unary_targets or []), + ] + binary_targets = [ + exir_ops.edge.aten.add.Tensor, + exir_ops.edge.aten.mul.Tensor, + *(additional_binary_targets or []), + ] + self._dispatch: dict[ + EdgeOpOverload, + tuple[ + Callable[[torch.fx.Node, torch.fx.Node], bool], + Callable[[torch.fx.Node, torch.fx.Node], bool], + ], + ] = {} + self._target_filters = target_filters or {} + for t in unary_targets: + self._dispatch[t] = ( + self._should_swap_elementwise, + self._swap_elementwise_slice, + ) + + for t in binary_targets: + self._dispatch[t] = ( + self._should_swap_binary_elementwise, + self._swap_binary_elementwise_slice, + ) + + @property + def targets(self) -> list[EdgeOpOverload]: + return [exir_ops.edge.aten.slice_copy.Tensor] + + def _should_swap_elementwise( + self, op_node: torch.fx.Node, slice_node: torch.fx.Node + ) -> bool: + full_size = prod(op_node.meta["val"].shape) + sliced_size = prod(slice_node.meta["val"].shape) + return sliced_size < full_size + + def _swap_elementwise_slice( + self, op_node: torch.fx.Node, slice_node: torch.fx.Node + ) -> bool: + op_input = op_node.args[0] + assert isinstance(op_input, torch.fx.Node) + graph = slice_node.graph + + slice_dim = get_arg(slice_node, "dim", int) + slice_start = get_arg(slice_node, "start") + slice_end = get_arg(slice_node, "end") + slice_step = get_arg(slice_node, "step", int) + + with graph.inserting_before(op_node): + new_slice = graph.call_function( + exir_ops.edge.aten.slice_copy.Tensor, + args=(op_input, slice_dim, slice_start, slice_end, slice_step), + ) + new_slice.meta["val"] = exir_ops.edge.aten.slice_copy.Tensor( + op_input.meta["val"], slice_dim, slice_start, slice_end, slice_step + ) + + new_args = list(op_node.args) + new_args[0] = new_slice + target = cast(EdgeOpOverload, op_node.target) + new_op = graph.call_function( + target, + args=tuple(new_args), + kwargs=op_node.kwargs, + ) + new_op.meta["val"] = target( + new_slice.meta["val"], + *[ + a.meta["val"] if isinstance(a, torch.fx.Node) else a + for a in new_args[1:] + ], + **{ + k: v.meta["val"] if isinstance(v, torch.fx.Node) else v + for k, v in op_node.kwargs.items() + }, + ) + + slice_node.replace_all_uses_with(new_op) + graph.erase_node(slice_node) + graph.erase_node(op_node) + return True + + def _should_swap_binary_elementwise( + self, op_node: torch.fx.Node, slice_node: torch.fx.Node + ) -> bool: + lhs, rhs = op_node.args[0], op_node.args[1] + assert isinstance(lhs, torch.fx.Node) and isinstance(rhs, torch.fx.Node) + if lhs.meta["val"].shape == rhs.meta["val"].shape: + return False + full_size = prod(op_node.meta["val"].shape) + sliced_size = prod(slice_node.meta["val"].shape) + return sliced_size < full_size + + def _swap_binary_elementwise_slice( + self, op_node: torch.fx.Node, slice_node: torch.fx.Node + ) -> bool: + lhs, rhs = op_node.args[0], op_node.args[1] + assert isinstance(lhs, torch.fx.Node) and isinstance(rhs, torch.fx.Node) + graph = slice_node.graph + + slice_dim = get_arg(slice_node, "dim", int) + slice_start = get_arg(slice_node, "start") + slice_end = get_arg(slice_node, "end") + slice_step = get_arg(slice_node, "step", int) + + output_shape = op_node.meta["val"].shape + output_dim = slice_dim % len(output_shape) + + new_args = list(op_node.args) + with graph.inserting_before(op_node): + for i, inp in enumerate([lhs, rhs]): + input_shape = inp.meta["val"].shape + # Broadcasting aligns operand dimensions to the right of the output. + input_dim = output_dim - (len(output_shape) - len(input_shape)) + if ( + input_dim >= 0 + and input_shape[input_dim] == output_shape[output_dim] + ): + new_slice = graph.call_function( + exir_ops.edge.aten.slice_copy.Tensor, + args=(inp, input_dim, slice_start, slice_end, slice_step), + ) + new_slice.meta["val"] = exir_ops.edge.aten.slice_copy.Tensor( + inp.meta["val"], + input_dim, + slice_start, + slice_end, + slice_step, + ) + new_args[i] = new_slice + + target = cast(EdgeOpOverload, op_node.target) + new_op = graph.call_function( + target, + args=tuple(new_args), + kwargs=op_node.kwargs, + ) + new_op.meta["val"] = target( + *[ + a.meta["val"] if isinstance(a, torch.fx.Node) else a + for a in new_args + ], + **{ + k: v.meta["val"] if isinstance(v, torch.fx.Node) else v + for k, v in op_node.kwargs.items() + }, + ) + + slice_node.replace_all_uses_with(new_op) + graph.erase_node(slice_node) + graph.erase_node(op_node) + return True + + def maybe_remove_or_replace(self, node: torch.fx.Node) -> bool: + parent = get_arg(node, "input", torch.fx.Node) + if len(parent.users) != 1: + return False + if not isinstance(parent.target, EdgeOpOverload): + return False + + entry = self._dispatch.get(parent.target) + if entry is None: + return False + target_filter = self._target_filters.get(parent.target) + if target_filter is not None and not target_filter(parent): + return False + + should_swap, do_swap = entry + return should_swap(parent, node) and do_swap(parent, node) diff --git a/backends/transforms/quantize_reorder_utils.py b/backends/transforms/quantize_reorder_utils.py new file mode 100644 index 00000000000..ba7a6d47115 --- /dev/null +++ b/backends/transforms/quantize_reorder_utils.py @@ -0,0 +1,51 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# pyre-unsafe + +import torch +from executorch.exir.dialects._ops import ops as exir_ops + +# A list of ops that can be trivially quantized +trivially_quantizable_ops_overloadpkt = { + exir_ops.edge.aten.chunk, + exir_ops.edge.aten.clone, + exir_ops.edge.aten.contiguous, + exir_ops.edge.aten.expand_copy, + exir_ops.edge.aten.permute_copy, + exir_ops.edge.aten.select_copy, + exir_ops.edge.aten.slice_copy, + exir_ops.edge.aten.squeeze_copy, + exir_ops.edge.aten.transpose_copy, + exir_ops.edge.aten.unfold_copy, + exir_ops.edge.aten.unsqueeze_copy, + exir_ops.edge.aten.view_copy, + torch.ops.aten.chunk, + torch.ops.aten.clone, + torch.ops.aten.contiguous, + torch.ops.aten.expand_copy, + torch.ops.aten.permute, + torch.ops.aten.permute_copy, + torch.ops.aten.select_copy, + torch.ops.aten.slice, + torch.ops.aten.slice_copy, + torch.ops.aten.squeeze, + torch.ops.aten.squeeze_copy, + torch.ops.aten.transpose, + torch.ops.aten.transpose_copy, + torch.ops.aten.unsqueeze, + torch.ops.aten.unsqueeze_copy, + torch.ops.aten.view, + torch.ops.aten.view_copy, +} + +# slice-equivalent ops +slice_or_select_overloadpkt = { + torch.ops.aten.slice_copy, + torch.ops.aten.select_copy, + exir_ops.edge.aten.slice_copy, + exir_ops.edge.aten.select_copy, +} diff --git a/backends/transforms/remove_alias_copy_op.py b/backends/transforms/remove_alias_copy_op.py new file mode 100644 index 00000000000..5dc41f5c7c3 --- /dev/null +++ b/backends/transforms/remove_alias_copy_op.py @@ -0,0 +1,31 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# pyre-unsafe + +from executorch.backends.transforms.permute_pass_utils import ( + RemoveOrReplacePassInterface, +) +from executorch.exir.dialects._ops import ops as exir_ops +from executorch.exir.dialects.edge._ops import EdgeOpOverload +from torch.fx.node import Node + + +class RemoveAliasCopyOpPass(RemoveOrReplacePassInterface): + """ + + alias_copy is a no-op and can be removed. + """ + + @property + def targets(self) -> list[EdgeOpOverload]: + return [exir_ops.edge.aten.alias_copy.default] + + def maybe_remove_or_replace(self, node: Node) -> bool: + input_node = node.args[0] + assert isinstance(input_node, Node) + node.replace_all_uses_with(input_node) + return True diff --git a/backends/transforms/remove_branched_quant_dequant.py b/backends/transforms/remove_branched_quant_dequant.py new file mode 100644 index 00000000000..f57c2d0ecc9 --- /dev/null +++ b/backends/transforms/remove_branched_quant_dequant.py @@ -0,0 +1,80 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# pyre-unsafe + +import torch +import torch.fx +from executorch.backends.transforms.permute_pass_utils import get_edge_overload_packet +from executorch.exir.dialects._ops import ops as exir_ops +from executorch.exir.dialects.edge._ops import EdgeOpOverload, EdgeOpOverloadPacket +from executorch.exir.pass_base import PassResult +from torch.fx.passes.infra.pass_base import PassBase + + +class RemoveBranchedQuantDequant(PassBase): + """ + This pass looks for adjacent quant and dequant nodes with identical + parameters, where the quant node has other users in addition to the + dequant. The quant and dequant pair would be removed by the + FuseQuantDequantToRequantizePass if not for the multiple users. This pass + removes just the dequant node by connecting it to the quant's parent node + """ + + quantize_op_packets: set[EdgeOpOverloadPacket] = { + exir_ops.edge.quantized_decomposed.quantize_per_tensor, + } + dequantize_op_packets: set[EdgeOpOverloadPacket] = { + exir_ops.edge.quantized_decomposed.dequantize_per_tensor, + } + + def call(self, graph_module: torch.fx.GraphModule) -> PassResult: + modified = self.remove_branched( + graph_module, self.quantize_op_packets, self.dequantize_op_packets + ) + modified |= self.remove_branched( + graph_module, self.dequantize_op_packets, self.quantize_op_packets + ) + + if modified: + graph_module.graph.eliminate_dead_code() + graph_module.recompile() + + return PassResult(graph_module, modified) + + def remove_branched( + self, + graph_module: torch.fx.GraphModule, + producer_pkts: set[EdgeOpOverloadPacket], + consumer_pkts: set[EdgeOpOverloadPacket], + ) -> bool: + modified = False + for node in graph_module.graph.nodes: + if ( + node.op != "call_function" + or not isinstance(node.target, EdgeOpOverload) + or get_edge_overload_packet(node.target) not in producer_pkts + ): + continue + + if len(node.users) < 2: + continue + + for user in node.users: + if ( + not isinstance(user.target, EdgeOpOverload) + or get_edge_overload_packet(user.target) not in consumer_pkts + ): + continue + + # check qparams match + if node.args[1:] != user.args[1:]: + continue + + user.replace_all_uses_with(node.args[0]) + modified = True + + return modified diff --git a/backends/transforms/remove_cat_from_slice_copy.py b/backends/transforms/remove_cat_from_slice_copy.py new file mode 100644 index 00000000000..73a56e15c32 --- /dev/null +++ b/backends/transforms/remove_cat_from_slice_copy.py @@ -0,0 +1,70 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# pyre-unsafe + +from typing import Optional, Sequence + +from executorch.backends.transforms.permute_pass_utils import ( + get_arg, + RemoveOrReplacePassInterface, + set_arg, +) +from executorch.exir.dialects._ops import ops as exir_ops +from executorch.exir.dialects.edge._ops import EdgeOpOverload +from torch.fx.node import Node + + +class RemoveCatFromSliceCopyPass(RemoveOrReplacePassInterface): + """ + Simplifies cat->slice_copy chains where one of the cat inputs can be directly passed + to the slice_copy. + """ + + @property + def targets(self) -> list[EdgeOpOverload]: + return [exir_ops.edge.aten.slice_copy.Tensor] + + def maybe_remove_or_replace(self, node: Node) -> bool: + cat_node = get_arg(node, "input", Node) + slice_dim = get_arg(node, "dim", int) + start_idx = get_arg(node, "start", Optional[int]) + end_idx = get_arg(node, "end", Optional[int]) + step = get_arg(node, "step", int) + + if cat_node.target != exir_ops.edge.aten.cat.default or step != 1: + return False + + # Make sure cat and slice happens on the same dimension. + cat_dim = get_arg(cat_node, "dim", int) + if cat_dim != slice_dim: + return False + + # Canonicalize slice indices. + cat_output_shape = cat_node.meta["val"].shape + if start_idx is None: + start_idx = 0 + elif start_idx < 0: + start_idx += cat_output_shape[cat_dim] + if end_idx is None or end_idx > cat_output_shape[cat_dim]: + end_idx = cat_output_shape[cat_dim] + elif end_idx < 0: + end_idx += cat_output_shape[cat_dim] + + offset = 0 + for cat_input_node in get_arg(cat_node, "tensors", Sequence[Node]): + cat_input_shape = cat_input_node.meta["val"].shape + + # Check if the slice range overlaps with the cat input range. + if offset <= start_idx and end_idx <= offset + cat_input_shape[cat_dim]: + node.replace_input_with(cat_node, cat_input_node) + set_arg(node, "start", start_idx - offset) + set_arg(node, "end", end_idx - offset) + return True + + offset += cat_input_shape[cat_dim] + + return False diff --git a/backends/transforms/remove_clone_ops_transform_imported.py b/backends/transforms/remove_clone_ops_transform_imported.py new file mode 100644 index 00000000000..3a9c19262bf --- /dev/null +++ b/backends/transforms/remove_clone_ops_transform_imported.py @@ -0,0 +1,26 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# pyre-unsafe + +from typing import List + +import torch +import torch.fx +from executorch.backends.transforms.remove_clone_ops import RemoveCloneOpsTransform +from executorch.exir.pass_base import ExportPass, PassResult +from executorch.exir.pass_manager import PassManager, PassType +from executorch.exir.passes import dead_code_elimination_pass + + +class RemoveCloneOpsTransformImported(ExportPass): + def call(self, graph_module: torch.fx.GraphModule) -> PassResult: + finalize_passes: List[PassType] = [ + RemoveCloneOpsTransform(eliminate_quant_dequant_pairs=False), + ] + result = PassManager(passes=finalize_passes)(graph_module) + dead_code_elimination_pass(result.graph_module) + return result diff --git a/backends/transforms/remove_nop_as_strided_copy_op.py b/backends/transforms/remove_nop_as_strided_copy_op.py new file mode 100644 index 00000000000..67ac4e983e0 --- /dev/null +++ b/backends/transforms/remove_nop_as_strided_copy_op.py @@ -0,0 +1,52 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# pyre-unsafe + +from typing import cast, Optional + +import torch +from executorch.backends.transforms.permute_pass_utils import ( + get_arg, + RemoveOrReplacePassInterface, +) +from executorch.exir.dialects._ops import ops as exir_ops +from executorch.exir.dialects.edge._ops import EdgeOpOverload +from torch.fx.node import Node + + +class RemoveNopAsStridedCopyOpPass(RemoveOrReplacePassInterface): + """Remove as_strided_copy ops that preserve every logical element.""" + + @property + def targets(self) -> list[EdgeOpOverload]: + return [exir_ops.edge.aten.as_strided_copy.default] + + def maybe_remove_or_replace(self, node: Node) -> bool: + input_node = node.args[0] + assert isinstance(input_node, Node) + input_value = cast(torch.Tensor, input_node.meta["val"]) + size = get_arg(node, "size", list[int]) + stride = get_arg(node, "stride", list[int]) + storage_offset = get_arg(node, "storage_offset", Optional[int]) + + if tuple(size) != tuple(input_value.shape) or len(stride) != len(size): + return False + if ( + storage_offset is not None + and storage_offset != input_value.storage_offset() + ): + return False + if not all( + dim_size == 1 or output_stride == input_stride + for dim_size, output_stride, input_stride in zip( + size, stride, input_value.stride() + ) + ): + return False + + node.replace_all_uses_with(input_node) + return True diff --git a/backends/transforms/remove_nop_expand_op.py b/backends/transforms/remove_nop_expand_op.py new file mode 100644 index 00000000000..3fb23b110d3 --- /dev/null +++ b/backends/transforms/remove_nop_expand_op.py @@ -0,0 +1,36 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# pyre-unsafe + +from executorch.backends.transforms.permute_pass_utils import ( + RemoveOrReplacePassInterface, +) +from executorch.exir.dialects._ops import ops as exir_ops +from executorch.exir.dialects.edge._ops import EdgeOpOverload +from torch.fx.node import Node + + +class RemoveNopExpandOpPass(RemoveOrReplacePassInterface): + """ + For an expand op, if the operator shape matches the expand shape, then the + expand is a nop. + """ + + @property + def targets(self) -> list[EdgeOpOverload]: + return [ + exir_ops.edge.aten.expand_copy.default, + exir_ops.edge.aten.expand.default, + ] + + def maybe_remove_or_replace(self, node: Node) -> bool: + input_node = node.args[0] + assert isinstance(input_node, Node) + if input_node.meta["val"].shape == node.meta["val"].shape: + node.replace_all_uses_with(input_node) + return True + return False diff --git a/backends/transforms/remove_nop_slice_or_view_op.py b/backends/transforms/remove_nop_slice_or_view_op.py new file mode 100644 index 00000000000..f15d095b7f7 --- /dev/null +++ b/backends/transforms/remove_nop_slice_or_view_op.py @@ -0,0 +1,37 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# pyre-unsafe + +from executorch.backends.transforms.permute_pass_utils import ( + RemoveOrReplacePassInterface, +) +from executorch.exir.dialects._ops import ops as exir_ops +from executorch.exir.dialects.edge._ops import EdgeOpOverload +from torch.fx.node import Node + + +class RemoveNopSliceOrViewOpPass(RemoveOrReplacePassInterface): + """ + Remove slice ops that are more like views, and view ops that do not change the shape + """ + + @property + def targets(self) -> list[EdgeOpOverload]: + return [ + exir_ops.edge.aten.slice_copy.Tensor, + exir_ops.edge.aten.view_copy.default, + ] + + def maybe_remove_or_replace(self, node: Node) -> bool: + changed = False + input_node = node.args[0] + assert isinstance(input_node, Node) + if input_node.meta["val"].shape == node.meta["val"].shape: + node.replace_all_uses_with(input_node) + changed = True + + return changed diff --git a/backends/transforms/remove_permute_before_mean.py b/backends/transforms/remove_permute_before_mean.py new file mode 100644 index 00000000000..4bf0d415c86 --- /dev/null +++ b/backends/transforms/remove_permute_before_mean.py @@ -0,0 +1,230 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# pyre-unsafe + +from typing import Optional + +import torch +import torch.fx +from executorch.backends.transforms.permute_pass_utils import ( + get_arg, + RemoveOrReplacePassInterface, +) +from executorch.exir.dialects._ops import ops as exir_ops +from executorch.exir.dialects.edge._ops import EdgeOpOverload +from torch.fx.node import Node +from torch.utils import _pytree as pytree + + +class RemovePermuteBeforeMeanPass(RemoveOrReplacePassInterface): + """Remove or sink permute ops that precede mean reductions through unary chains. + + When a permute feeds into a mean (possibly through unary ops like + dequantize/quantize), two optimizations apply: + + 1. If non-reduced dims maintain their relative order and positions, the + permute is fully removed and the mean's reduction dims are remapped. + 2. Otherwise, the permute is moved after the mean so it operates on + smaller data. + + Cost model + ---------- + Let S_in = input size in bytes, S_out = output size in bytes, + R = S_in / S_out (reduction ratio), C_c = contiguous mean compute + cost, C_s = strided mean compute cost, and Delta = C_s - C_c. + + Original graph (permute -> mean): + Cost_orig = 3*S_in + S_out + C_c + Breakdown: permute reads and writes S_in (2*S_in), mean reads + S_in and writes S_out. + + Case 1 -- full removal (mean with remapped dims, no permute): + Cost_remove = S_in + S_out + C_s + Profitable when: Delta < 2*S_in + The strided access penalty must be less than twice the full + input tensor I/O (the eliminated permute cost). + + Case 2 -- reorder (mean with remapped dims -> small permute): + Cost_reorder = S_in + 3*S_out + C_s + Profitable when: Delta < 2*(S_in - S_out) = 2*S_in*(R-1)/R + At R = 4 the threshold is 1.5*S_in; at R = 16 it approaches + 2*S_in, converging to the full-removal bound as S_out becomes + negligible. + + Full removal always dominates reorder when both are structurally + possible (Cost_remove < Cost_reorder since S_out < 3*S_out), so + removal is applied without a cost gate. + + Additionally, if the original permute does not place the reduction + dims as the trailing (innermost) dimensions, the mean is already + strided in the original graph. Removing or sinking the permute + saves the permute I/O (2*S_in) while the mean performance can only + improve or stay the same. This makes the transformation + unconditionally profitable without needing the cost model. + """ + + _UNARY_TARGETS: frozenset[EdgeOpOverload] = frozenset( + { + exir_ops.edge.quantized_decomposed.dequantize_per_tensor.default, + exir_ops.edge.quantized_decomposed.quantize_per_tensor.default, + exir_ops.edge.aten.clone.default, + exir_ops.edge.aten.relu.default, + exir_ops.edge.aten.neg.default, + exir_ops.edge.aten.abs.default, + } + ) + + _MIN_REDUCTION_RATIO_FOR_REORDER: int = 4 + + @property + def targets(self) -> list[EdgeOpOverload]: + return [exir_ops.edge.aten.mean.dim] + + def _find_permute_through_unary_chain(self, mean_node: Node) -> Optional[Node]: + """Walk backward from mean through single-user unary ops to find a permute.""" + current = mean_node.args[0] + if not isinstance(current, Node): + return None + while True: + if current.target == exir_ops.edge.aten.permute_copy.default: + return current + if current.target not in self._UNARY_TARGETS: + return None + if len(current.users) != 1: + return None + parent = current.args[0] + if not isinstance(parent, Node): + return None + current = parent + + @staticmethod + def _reduction_dims_are_trailing(reduction_dims: list[int], ndim: int) -> bool: + """Check whether all reduction dims are the trailing (innermost) dims.""" + canonical = sorted(d % ndim for d in reduction_dims) + return canonical == list(range(ndim - len(canonical), ndim)) + + def _is_reorder_profitable( + self, + reduction_dims: list[int], + new_reduction_dims: list[int], + ndim: int, + input_shape: torch.Size, + ) -> bool: + """Determine whether sinking the permute past the mean is profitable. + + Three cases, in order of evaluation: + + 1. The original permute does not place the reduction dims as trailing + (innermost) dimensions. The mean is already strided, so removing + the permute saves 2*S_in with no mean degradation. Always + profitable. + + 2. The new (remapped) reduction dims are trailing in the original + layout. The mean stays contiguous after the transformation + (Delta ~ 0). Always profitable. + + 3. The mean becomes strided. The reorder is profitable when + Delta < 2*S_in*(R-1)/R. We approximate this by requiring + R >= _MIN_REDUCTION_RATIO_FOR_REORDER, ensuring the I/O savings + from operating on a smaller tensor outweigh the strided access + penalty. + """ + if not self._reduction_dims_are_trailing(reduction_dims, ndim): + return True + + if self._reduction_dims_are_trailing(new_reduction_dims, ndim): + return True + + reduction_ratio = 1 + canonical_reduction = set(new_reduction_dims) + for d in canonical_reduction: + reduction_ratio *= input_shape[d] + + return reduction_ratio >= self._MIN_REDUCTION_RATIO_FOR_REORDER + + def maybe_remove_or_replace(self, node: Node) -> bool: + reduction_dims = get_arg(node, "dim", list[int]) + + permute_node = self._find_permute_through_unary_chain(node) + if permute_node is None: + return False + + perm = get_arg(permute_node, "dims", list[int]) + ndim = len(perm) + + if len(permute_node.users) != 1: + return False + + permute_input = permute_node.args[0] + assert isinstance(permute_input, Node) + + new_reduction_dims = [perm[d] for d in reduction_dims] + keepdim = get_arg(node, "keepdim", bool) + + # Determine if the permute can be fully removed (post-mean permute + # would be a no-op) vs needing to be sunk after the mean. + canonical_reduction = set(new_reduction_dims) + if keepdim: + can_remove = all( + perm[d] == d for d in range(ndim) if d not in canonical_reduction + ) + else: + non_reduced_in_perm_order = [ + d for d in perm if d not in canonical_reduction + ] + can_remove = non_reduced_in_perm_order == sorted(non_reduced_in_perm_order) + + # Full removal is almost always profitable. Reorder requires a + # tighter cost bound; verify via the cost model before proceeding. + if not can_remove: + input_shape = permute_input.meta["val"].shape + if not self._is_reorder_profitable( + reduction_dims, new_reduction_dims, ndim, input_shape + ): + return False + + # Rewire: the permute's single user (either the mean itself or the + # first unary op in the chain) should read from the permute's input. + permute_user = next(iter(permute_node.users)) + permute_user.replace_input_with(permute_node, permute_input) + node.args = (node.args[0], new_reduction_dims) + node.args[2:] + + # Re-derive the mean's meta since its reduction dims changed. + fake_args = pytree.tree_map( + lambda x: x.meta["val"] if isinstance(x, Node) else x, + node.args, + ) + node.meta["val"] = node.target(*fake_args) # pyre-ignore[29] + + if not can_remove: + # Compute the post-mean permute on the reduced output. + if keepdim: + post_perm = list(perm) + else: + non_reduced_original = sorted( + d for d in range(ndim) if d not in canonical_reduction + ) + non_reduced_permuted = [d for d in perm if d not in canonical_reduction] + post_perm = [ + non_reduced_original.index(d) for d in non_reduced_permuted + ] + + graph = node.graph + with graph.inserting_after(node): + new_permute = graph.create_node( + "call_function", + exir_ops.edge.aten.permute_copy.default, + args=(node, post_perm), + ) + new_permute.meta["val"] = exir_ops.edge.aten.permute_copy.default( + node.meta["val"], post_perm + ) + for user in list(node.users): + if user is not new_permute: + user.replace_input_with(node, new_permute) + + return True diff --git a/backends/transforms/remove_permutes_around_elementwise_ops.py b/backends/transforms/remove_permutes_around_elementwise_ops.py index bea6782ba5a..332abdd7101 100644 --- a/backends/transforms/remove_permutes_around_elementwise_ops.py +++ b/backends/transforms/remove_permutes_around_elementwise_ops.py @@ -7,6 +7,7 @@ # pyre-unsafe +import math from collections.abc import Callable from dataclasses import dataclass, field from typing import cast @@ -791,6 +792,36 @@ def permute_subgraph(self, subgraph: Subgraph) -> bool: # noqa: C901 if len(perm) != len(inp.meta["val"].shape): return False + # Validate: no node in the region may broadcast operands of different + # rank. Broadcasting right-aligns shapes, so once the boundary permutes + # are removed a lower-rank operand lines up against different axes of + # the others and the region silently computes a differently shaped + # result -- a [216, 1] that met a [1, 1, 216, 1] now meets a + # [1, 216, 1, 1] and yields 216 * 216 elements. The mismatch only + # surfaces later, as a downstream view whose recorded shape no longer + # fits its input. Same-rank broadcasting is safe: every operand carries + # the region's permutation, and broadcasting commutes with applying one + # permutation to all operands. numel-1 operands broadcast identically + # under any permutation, and cat never broadcasts. + for node in subgraph.nodes: + if ( + node in interleave_nodes + or node.target in self._VIEW_OPS + or node.target == exir_ops.edge.aten.cat.default + ): + continue + operand_ranks = { + len(shape) + for shape in ( + self._concrete_shape(inp) + for inp in node.all_input_nodes + if isinstance(inp.meta.get("val"), torch.Tensor) + ) + if shape is not None and math.prod(shape) > 1 + } + if len(operand_ranks) > 1: + return False + # Handle dimension related node arguments FIRST, before # bypassing permutes (which changes node inputs/metadata). for node in subgraph.nodes: diff --git a/backends/transforms/remove_zero_sized_cat_args.py b/backends/transforms/remove_zero_sized_cat_args.py new file mode 100644 index 00000000000..4a78052815d --- /dev/null +++ b/backends/transforms/remove_zero_sized_cat_args.py @@ -0,0 +1,67 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# pyre-unsafe + +from executorch.backends.transforms.permute_pass_utils import ( + RemoveOrReplacePassInterface, +) +from executorch.exir.dialects._ops import ops as exir_ops +from executorch.exir.dialects.edge._ops import EdgeOpOverload +from torch.fx.node import Node + + +class RemoveZeroSizedCatArgsPass(RemoveOrReplacePassInterface): + @property + def targets(self) -> list[EdgeOpOverload]: + return [exir_ops.edge.aten.cat.default] + + def maybe_remove_or_replace(self, node: Node) -> bool: + # Get the cat inputs (first argument is a list of tensors) + cat_inputs_arg = node.args[0] + + # Assert that cat_inputs_arg is iterable + assert isinstance( + cat_inputs_arg, (list, tuple) + ), "cat_inputs_arg must be a sequence type" + + # Filter out zero-sized tensors + cat_inputs: list[Node] = [] + for arg in cat_inputs_arg: + if isinstance(arg, Node) and arg.meta.get("val") is not None: + if arg.meta["val"].numel() > 0: + cat_inputs.append(arg) + + # If all tensors were empty, create a full op with the right shape + if not cat_inputs: + empty_shape = node.meta["val"].shape + dtype = node.meta["val"].dtype + # Create a new full node + with node.graph.inserting_before(node): + full_node = node.graph.call_function( + exir_ops.edge.aten.full.default, + args=(tuple(empty_shape), 0), + kwargs={"dtype": dtype}, + ) + full_node.meta = node.meta.copy() + node.replace_all_uses_with(full_node) + return True + + # If only one tensor remains, replace with it + if len(cat_inputs) == 1: + node.replace_all_uses_with(cat_inputs[0]) + return True + + # If the number of inputs changed, update the cat args + if len(cat_inputs) < len(cat_inputs_arg): + # Update the first argument with filtered inputs + new_args = list(node.args) + new_args[0] = cat_inputs + node.args = tuple(new_args) + return True + + # No changes needed + return False diff --git a/backends/transforms/replace_select_with_view_op.py b/backends/transforms/replace_select_with_view_op.py new file mode 100644 index 00000000000..07e1317a399 --- /dev/null +++ b/backends/transforms/replace_select_with_view_op.py @@ -0,0 +1,52 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# pyre-unsafe + +import torch +import torch.fx +from executorch.backends.transforms.permute_pass_utils import ( + RemoveOrReplacePassInterface, +) +from executorch.exir.dialects._ops import ops as exir_ops +from executorch.exir.dialects.edge._ops import EdgeOpOverload + + +class ReplaceSelectWithViewOpPass(RemoveOrReplacePassInterface): + """ + If the size along the select dim is 1, then the select op can be replaced + by view op. + """ + + @property + def targets(self) -> list[EdgeOpOverload]: + return [exir_ops.edge.aten.select_copy.int] + + def maybe_remove_or_replace(self, node: torch.fx.Node) -> bool: + # Get the input tensor and shapes + in_tensor_node = node.args[0] + assert isinstance(in_tensor_node, torch.fx.Node) + in_shape = in_tensor_node.meta["val"].shape + out_shape = node.meta["val"].shape + + # Get the select dimension + select_dim = node.args[1] + assert isinstance(select_dim, int) + select_dim = select_dim if select_dim >= 0 else select_dim + len(in_shape) + + if in_shape[select_dim] == 1: + # Replace with view op with the new shape + with node.graph.inserting_before(node): + new_node = node.graph.call_function( + exir_ops.edge.aten.view_copy.default, + args=(node.args[0], list(out_shape)), + ) + # Important to copy metadata + new_node.meta = node.meta + node.replace_all_uses_with(new_node) + return True + + return False diff --git a/backends/transforms/replace_split_with_slice.py b/backends/transforms/replace_split_with_slice.py new file mode 100644 index 00000000000..6272e70e9cf --- /dev/null +++ b/backends/transforms/replace_split_with_slice.py @@ -0,0 +1,89 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# pyre-unsafe + +import operator +from typing import Optional + +import torch +import torch.fx +from executorch.backends.transforms.permute_pass_utils import ( + RemoveOrReplacePassInterface, +) +from executorch.exir.dialects._ops import ops as exir_ops +from executorch.exir.dialects.edge._ops import EdgeOpOverload + + +class ReplaceSplitWithSlicePass(RemoveOrReplacePassInterface): + """ + split_with_sizes() delegates to slice() op, so perform this replacement here. + This avoids the expense of delegation from ATen. + """ + + @property + def targets(self) -> list[EdgeOpOverload]: + return [exir_ops.edge.aten.split_with_sizes_copy.default] + + def maybe_remove_or_replace(self, node: torch.fx.Node) -> bool: + # All the users of this split_with_sizes op must be getitem ops + if any(user.target != operator.getitem for user in node.users): + return False + + # Get the slice dim and extent for each split + slice_ops = self._get_split_sizes(node) + if slice_ops is None: + return False + + graph = node.graph + + # Go over each getitem user, and replace it with slice op + for user in list(node.users.keys()): + assert user.target == operator.getitem + item_idx = int(user.args[1]) + assert item_idx < len(slice_ops) + cur_slice = slice_ops[item_idx] + with graph.inserting_before(user): + cur_slice_node = graph.call_function( + exir_ops.edge.aten.slice_copy.Tensor, + (node.args[0], cur_slice[0], cur_slice[1], cur_slice[2], 1), + ) + # Metadata copy important + cur_slice_node.meta = user.meta + user.replace_all_uses_with(cur_slice_node) + + # Return True to indicate the split node should be removed + return True + + def _get_split_sizes(self, node: torch.fx.Node) -> Optional[list[tuple[int, ...]]]: + """For split_with_sizes, return the slice dim and extent for each split.""" + # Parse the args of the split_with_sizes op + tensor_arg, split_sizes = node.args[0:2] + assert isinstance(tensor_arg, torch.fx.Node) + + # Get shape from node metadata + val = tensor_arg.meta.get("val") + if val is None: + return None + in_shape = val.shape + + split_dim = 0 if len(node.args) < 3 else node.args[2] + + # Canonicalize the split dimension + assert isinstance(split_dim, int) + split_dim = split_dim if split_dim >= 0 else len(in_shape) + split_dim + + # Create the slice op args corresponding to each split + slice_ops = [] + split_start = 0 + assert isinstance(split_sizes, list) + for split_size in split_sizes: + split_end = split_start + split_size + slice_args = (split_dim, split_start, split_end) + slice_ops.append(slice_args) + split_start = split_end + + return slice_ops diff --git a/backends/transforms/split_dequantized_cat.py b/backends/transforms/split_dequantized_cat.py new file mode 100644 index 00000000000..61f0a31526e --- /dev/null +++ b/backends/transforms/split_dequantized_cat.py @@ -0,0 +1,86 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# pyre-unsafe + +from collections import defaultdict +from typing import DefaultDict, List, Tuple + +import torch +import torch.fx +from executorch.backends.transforms.permute_pass_utils import ( + get_arg, + get_overload_packet, + RemoveOrReplacePassInterface, +) +from executorch.exir.dialects._ops import ops as exir_ops +from executorch.exir.dialects.edge._ops import EdgeOpOverload + + +class SplitDequantizedCatPass(RemoveOrReplacePassInterface): + """Split a cat node so that quantize consumers get their own copy. + + Fires when a cat has all floating-point inputs, at least one dequantize + input, and at least one quantize consumer. Quant consumers are grouped + by matching qparams; each group receives a dedicated duplicate of the + cat node. Non-quant consumers stay on the original cat, whose + semantics are unchanged. + + A later pass (e.g. AdvanceQuantizeOpAboveDefChainPass extended for cat) + can then hoist each quant above its single-consumer cat copy without + affecting the non-quant paths. + """ + + quantize_op_packets: set[object] = { + exir_ops.edge.quantized_decomposed.quantize_per_tensor, + } + dequantize_op_packets: set[object] = { + exir_ops.edge.quantized_decomposed.dequantize_per_tensor, + } + + @property + def targets(self) -> list[EdgeOpOverload]: + return [exir_ops.edge.aten.cat.default] + + def maybe_remove_or_replace(self, node: torch.fx.Node) -> bool: + cat_inputs = node.args[0] + if not isinstance(cat_inputs, (list, tuple)): + return False + + has_dequant_input = False + for inp in cat_inputs: + assert isinstance(inp, torch.fx.Node) + val = inp.meta["val"] + if val is None or not val.is_floating_point(): + return False + if get_overload_packet(inp.target) in self.dequantize_op_packets: + has_dequant_input = True + + if not has_dequant_input: + return False + + quant_groups: DefaultDict[Tuple, List[torch.fx.Node]] = defaultdict(list) + for user in list(node.users.keys()): + if get_overload_packet(user.target) in self.quantize_op_packets: + quant_groups[user.args[1:]].append(user) + + if not quant_groups: + return False + + graph = node.graph + dim = get_arg(node, "dim", int) + for quant_consumers in quant_groups.values(): + with graph.inserting_after(node): + dup_cat = graph.call_function( + exir_ops.edge.aten.cat.default, + args=(list(cat_inputs), dim), + ) + dup_cat.meta = node.meta.copy() + + for q_node in quant_consumers: + q_node.replace_input_with(node, dup_cat) + + return True diff --git a/backends/transforms/targets.bzl b/backends/transforms/targets.bzl index 4dd160aa44d..4cd4db88fa6 100644 --- a/backends/transforms/targets.bzl +++ b/backends/transforms/targets.bzl @@ -920,3 +920,303 @@ def define_common_targets(): "fbsource//third-party/pypi/pytest:pytest", ], ) + + runtime.python_library( + name = "quantize_reorder_utils", + srcs = ["quantize_reorder_utils.py"], + visibility = [ + "//executorch/backends/...", + ], + deps = [ + "//caffe2:torch", + "//executorch/exir/dialects:lib", + ], + ) + + runtime.python_library( + name = "fuse_quant_dequant_to_requantize", + srcs = ["fuse_quant_dequant_to_requantize.py"], + visibility = [ + "//executorch/backends/...", + ], + deps = [ + "//caffe2:torch", + "//executorch/exir/dialects/edge:lib", + "//executorch/exir/dialects:lib", + "//executorch/exir:pass_base", + ":permute_pass_utils", + ], + ) + + runtime.python_library( + name = "remove_alias_copy_op", + srcs = ["remove_alias_copy_op.py"], + visibility = [ + "//executorch/backends/...", + ], + deps = [ + "//caffe2:torch", + "//executorch/exir/dialects/edge:lib", + "//executorch/exir/dialects:lib", + ":permute_pass_utils", + ], + ) + + runtime.python_library( + name = "remove_branched_quant_dequant", + srcs = ["remove_branched_quant_dequant.py"], + visibility = [ + "//executorch/backends/...", + ], + deps = [ + "//caffe2:torch", + "//executorch/exir/dialects/edge:lib", + "//executorch/exir/dialects:lib", + "//executorch/exir:pass_base", + ":permute_pass_utils", + ], + ) + + runtime.python_library( + name = "remove_cat_from_slice_copy", + srcs = ["remove_cat_from_slice_copy.py"], + visibility = [ + "//executorch/backends/...", + ], + deps = [ + "//caffe2:torch", + "//executorch/exir/dialects/edge:lib", + "//executorch/exir/dialects:lib", + ":permute_pass_utils", + ], + ) + + runtime.python_library( + name = "remove_clone_ops_transform_imported", + srcs = ["remove_clone_ops_transform_imported.py"], + visibility = [ + "//executorch/backends/...", + ], + deps = [ + "//caffe2:torch", + "//executorch/exir/passes:lib", + "//executorch/exir:pass_base", + "//executorch/exir:pass_manager", + ":remove_clone_ops", + ], + ) + + runtime.python_library( + name = "remove_nop_expand_op", + srcs = ["remove_nop_expand_op.py"], + visibility = [ + "//executorch/backends/...", + ], + deps = [ + "//caffe2:torch", + "//executorch/exir/dialects/edge:lib", + "//executorch/exir/dialects:lib", + ":permute_pass_utils", + ], + ) + + runtime.python_library( + name = "remove_nop_slice_or_view_op", + srcs = ["remove_nop_slice_or_view_op.py"], + visibility = [ + "//executorch/backends/...", + ], + deps = [ + "//caffe2:torch", + "//executorch/exir/dialects/edge:lib", + "//executorch/exir/dialects:lib", + ":permute_pass_utils", + ], + ) + + runtime.python_library( + name = "remove_permute_before_mean", + srcs = ["remove_permute_before_mean.py"], + visibility = [ + "//executorch/backends/...", + ], + deps = [ + "//caffe2:torch", + "//executorch/exir/dialects/edge:lib", + "//executorch/exir/dialects:lib", + ":permute_pass_utils", + ], + ) + + runtime.python_library( + name = "remove_zero_sized_cat_args", + srcs = ["remove_zero_sized_cat_args.py"], + visibility = [ + "//executorch/backends/...", + ], + deps = [ + "//caffe2:torch", + "//executorch/exir/dialects/edge:lib", + "//executorch/exir/dialects:lib", + ":permute_pass_utils", + ], + ) + + runtime.python_library( + name = "advance_quantize_op_above_def_in_branch", + srcs = ["advance_quantize_op_above_def_in_branch.py"], + visibility = [ + "//executorch/backends/...", + ], + deps = [ + "//caffe2:torch", + "//executorch/exir/dialects:lib", + "//executorch/exir:pass_base", + ":permute_pass_utils", + ":quantize_reorder_utils", + ], + ) + + runtime.python_library( + name = "advance_quantize_op_above_def_chain", + srcs = ["advance_quantize_op_above_def_chain.py"], + visibility = [ + "//executorch/backends/...", + ], + deps = [ + "//caffe2:torch", + "//executorch/exir/dialects/edge:lib", + "//executorch/exir/dialects:lib", + "//executorch/exir:pass_base", + ":permute_pass_utils", + ":quantize_reorder_utils", + ], + ) + + runtime.python_library( + name = "postpone_dequantize_op_below_use_chain", + srcs = ["postpone_dequantize_op_below_use_chain.py"], + visibility = [ + "//executorch/backends/...", + ], + deps = [ + "//caffe2:torch", + "//executorch/exir/dialects:lib", + "//executorch/exir:pass_base", + "//executorch/exir:tensor", + ":permute_pass_utils", + ":quantize_reorder_utils", + ], + ) + + runtime.python_library( + name = "move_permute_after_concat", + srcs = ["move_permute_after_concat.py"], + visibility = [ + "//executorch/backends/...", + ], + deps = [ + "//caffe2:torch", + "//executorch/exir/dialects/edge:lib", + "//executorch/exir/dialects:lib", + ":permute_pass_utils", + ], + ) + + runtime.python_library( + name = "move_slice_before_permute", + srcs = ["move_slice_before_permute.py"], + visibility = [ + "//executorch/backends/...", + ], + deps = [ + "//caffe2:torch", + "//executorch/exir/dialects/edge:lib", + "//executorch/exir/dialects:lib", + ":permute_pass_utils", + ], + ) + + runtime.python_library( + name = "move_slice_before_view", + srcs = ["move_slice_before_view.py"], + visibility = [ + "//executorch/backends/...", + ], + deps = [ + "//caffe2:torch", + "//executorch/exir/dialects/edge:lib", + "//executorch/exir/dialects:lib", + ":permute_pass_utils", + ], + ) + + runtime.python_library( + name = "propagate_slice", + srcs = ["propagate_slice.py"], + visibility = [ + "//executorch/backends/...", + ], + deps = [ + "//caffe2:torch", + "//executorch/exir/dialects/edge:lib", + "//executorch/exir/dialects:lib", + ":permute_pass_utils", + ], + ) + + runtime.python_library( + name = "split_dequantized_cat", + srcs = ["split_dequantized_cat.py"], + visibility = [ + "//executorch/backends/...", + ], + deps = [ + "//caffe2:torch", + "//executorch/exir/dialects/edge:lib", + "//executorch/exir/dialects:lib", + ":permute_pass_utils", + ], + ) + + runtime.python_library( + name = "replace_select_with_view_op", + srcs = ["replace_select_with_view_op.py"], + visibility = [ + "//executorch/backends/...", + ], + deps = [ + "//caffe2:torch", + "//executorch/exir/dialects/edge:lib", + "//executorch/exir/dialects:lib", + ":permute_pass_utils", + ], + ) + + runtime.python_library( + name = "replace_split_with_slice", + srcs = ["replace_split_with_slice.py"], + visibility = [ + "//executorch/backends/...", + ], + deps = [ + "//caffe2:torch", + "//executorch/exir/dialects/edge:lib", + "//executorch/exir/dialects:lib", + ":permute_pass_utils", + ], + ) + + runtime.python_library( + name = "remove_nop_as_strided_copy_op", + srcs = ["remove_nop_as_strided_copy_op.py"], + visibility = [ + "//executorch/backends/...", + ], + deps = [ + "//caffe2:torch", + "//executorch/exir/dialects:lib", + "//executorch/exir/dialects/edge:lib", + ":permute_pass_utils", + ], + ) diff --git a/backends/transforms/test/test_permute_optimization_passes.py b/backends/transforms/test/test_permute_optimization_passes.py index 7d1f507ccf0..d5f16609c32 100644 --- a/backends/transforms/test/test_permute_optimization_passes.py +++ b/backends/transforms/test/test_permute_optimization_passes.py @@ -1756,6 +1756,71 @@ def test_permutation_sink_view_terminal_broadcast_is_optimized(self) -> None: "permutation_sink_view_terminal_broadcast_is_optimized", ) + def test_numel_one_operand_does_not_block_region(self) -> None: + x_data = torch.randn(1, 4, 2, 3) + scale_data = torch.tensor(0.5) + builder = GraphBuilder() + x = builder.placeholder("x", x_data) + scale = builder.placeholder("scale", scale_data) + permute_in = builder.call_operator( + op=exir_ops.edge.aten.permute_copy.default, args=(x, [0, 2, 3, 1]) + ) + mul = builder.call_operator( + op=exir_ops.edge.aten.mul.Tensor, args=(permute_in, scale) + ) + permute_out = builder.call_operator( + op=exir_ops.edge.aten.permute_copy.default, args=(mul, [0, 3, 1, 2]) + ) + builder.output([permute_out]) + original = builder.get_graph_module() + gm_before = copy.deepcopy(original) + + result = cast(PassResult, RemovePermutesAroundElementwiseOps()(original)) + self.assertTrue(result.modified) + self.assertEqual( + count_node(result.graph_module, exir_ops.edge.aten.permute_copy.default), 0 + ) + validate_numerics( + gm_before, + result.graph_module, + [x_data, scale_data], + "numel_one_operand_does_not_block_region", + ) + + def test_cat_with_different_concat_sizes_does_not_block_region(self) -> None: + x_data = torch.randn(1, 4, 2, 2) + y_data = torch.randn(1, 6, 2, 2) + builder = GraphBuilder() + x = builder.placeholder("x", x_data) + y = builder.placeholder("y", y_data) + permute_x = builder.call_operator( + op=exir_ops.edge.aten.permute_copy.default, args=(x, [0, 2, 3, 1]) + ) + permute_y = builder.call_operator( + op=exir_ops.edge.aten.permute_copy.default, args=(y, [0, 2, 3, 1]) + ) + cat = builder.call_operator( + op=exir_ops.edge.aten.cat.default, args=([permute_x, permute_y], 3) + ) + permute_out = builder.call_operator( + op=exir_ops.edge.aten.permute_copy.default, args=(cat, [0, 3, 1, 2]) + ) + builder.output([permute_out]) + original = builder.get_graph_module() + gm_before = copy.deepcopy(original) + + result = cast(PassResult, RemovePermutesAroundElementwiseOps()(original)) + self.assertTrue(result.modified) + self.assertEqual( + count_node(result.graph_module, exir_ops.edge.aten.permute_copy.default), 0 + ) + validate_numerics( + gm_before, + result.graph_module, + [x_data, y_data], + "cat_with_different_concat_sizes_does_not_block_region", + ) + def test_split_sink_view_is_not_remapped_for_broadcast(self) -> None: x_data = torch.randn(1, 4, 2) sink_data = torch.randn(1, 1, 1, 8) From f1240a2fa81a607912211d68a104e3e27231b561 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Tue, 3 Nov 2026 12:46:17 -0800 Subject: [PATCH 020/128] Python runtime: stop a backend's log from deadlocking 2 threads (#24393) ## What is wrong today 2 threads can hang forever when a backend logs while holding a lock, as XNNPACK does when a load fails. A log line from a thread that holds the GIL (Python's global interpreter lock) goes to `sys.stderr` at once, and that Python write can let another thread take the GIL. If that thread then waits for the lock, neither thread moves again: ``` thread A: load_method -> takes lock L -> logs -> sys.stderr.write releases the GIL thread B: takes the GIL -> execute -> waits for lock L, holding the GIL thread A: waits for the GIL forever ``` ## What this change does The log function never runs Python code. While `sys.stderr` is the process's stderr (fd 2), each line goes straight to fd 2. When Python has replaced `sys.stderr` (`capsys`, `redirect_stderr`, a Jupyter notebook), the lines wait: - A binding call's lines are written as it returns, with no backend lock held. - A line that Python's main thread logs outside a call while holding the GIL (for example while the LLM runner loads) is written before the next Python code runs, so it still lands in a short `redirect_stderr` block. - Other threads' lines go to a thread that the bindings start. It never writes from inside another thread's `sys.stderr.write`, so a stream whose `write` takes a lock cannot hang. Outside a binding call, threads that do not hold the GIL write to fd 2, as today. Up to 1 MiB waits, keeping the newest. On Linux and macOS, a line also waits while fd 2 is a pipe or socket that stays full, so a GIL holder does not block while a Python thread drains it (`wurlitzer.pipes()`). A terminal is written at once, since its reader is normally another process. A fatal line goes to fd 2 at once while there is room. On Linux with Python before 3.13.8, a daemon thread never writes through Python. Today it does, and Python can end it mid-write at exit, which crashes the process. ## Tests New tests cover the hang (also from the LLM runner), a stream whose `write` takes a lock, a full pipe or socket drained by a thread, lines inside a short redirect, daemon threads at exit, and cut error messages. Ran them on Linux x86_64 and macOS, and the logging cases in Jupyter with fd capture on and off. Also fixed: an error message with invalid UTF-8, such as a corrupt program's identifier, raised `UnicodeDecodeError`, not `RuntimeError`. The docs on registering a PAL (platform abstraction layer) now use the right namespace and a complete example. --- .../using-executorch-runtime-integration.md | 50 +- extension/pybindings/README.md | 43 +- extension/pybindings/pybindings.cpp | 561 +++++++++++- extension/pybindings/test/BUCK | 2 + extension/pybindings/test/test_pybindings.py | 812 ++++++++++++++++++ 5 files changed, 1425 insertions(+), 43 deletions(-) diff --git a/docs/source/using-executorch-runtime-integration.md b/docs/source/using-executorch-runtime-integration.md index cac59affdff..0bf94fa4f5f 100644 --- a/docs/source/using-executorch-runtime-integration.md +++ b/docs/source/using-executorch-runtime-integration.md @@ -36,33 +36,45 @@ To register a custom PAL implementation, take the following steps: - Create an instance of the [PalImpl](https://github.com/pytorch/executorch/blob/7b39a0ce63bfb5124d4d29cfb6c8af85a3c580ba/runtime/platform/platform.h#L163) struct. - Set one or more fields to custom PAL function implementations. Leave fields as null to use the default platform implementation. - The PalImpl struct provides a [create](https://github.com/pytorch/executorch/blob/7b39a0ce63bfb5124d4d29cfb6c8af85a3c580ba/runtime/platform/platform.h#L168) method for this purpose. -- Call `executorch::platform::register_pal(pal_impl)` to register the implementation. +- Call `executorch::runtime::register_pal(pal_impl)` to register the implementation. - This can be done from as as a global constructor, as in the example below. -Here is a complete example from [pybindings.cpp](https://github.com/pytorch/executorch/blob/7b39a0ce63bfb5124d4d29cfb6c8af85a3c580ba/extension/pybindings/pybindings.cpp#L1178), where logs are redirected to show up properly in a Python notebook environment. +Here is a complete example that writes each log line to stderr. ```cpp +#include + +#include + namespace { - void emit_log_message( - et_timestamp_t timestamp, - et_pal_log_level_t level, - const char* filename, - ET_UNUSED const char* function, - size_t line, - const char* message, - ET_UNUSED size_t length) { - std::cerr << "[" << filename << ":" << line << "] " << message << std::endl; - } - - runtime::PalImpl build_pal() { - return runtime::PalImpl::create(emit_log_message, __FILE__); - } - - // Update PAL to redirect logs. - ET_UNUSED bool registration_result = runtime::register_pal(build_pal()); + +void emit_log_message( + ET_UNUSED et_timestamp_t timestamp, + ET_UNUSED et_pal_log_level_t level, + const char* filename, + ET_UNUSED const char* function, + size_t line, + const char* message, + size_t length) { + std::fprintf( + stderr, "[%s:%zu] %.*s\n", filename, line, static_cast(length), message); + std::fflush(stderr); } + +ET_UNUSED const bool registered = executorch::runtime::register_pal( + executorch::runtime::PalImpl::create(emit_log_message, __FILE__)); + +} // namespace ``` +The Python bindings register their PAL the same way, in +[pybindings.cpp](https://github.com/pytorch/executorch/blob/main/extension/pybindings/pybindings.cpp). +A backend can log while it holds a lock, and a log function that writes to +Python at that moment can deadlock with another thread that waits for that lock, +so their log function never calls Python. Their +[README](https://github.com/pytorch/executorch/blob/main/extension/pybindings/README.md#note) +says where their lines go. + ### Weak Symbol Override ExecuTorch also provides a link-time method to override the PAL using weak symbols. This method is primarily maintained for backwards compatibility. diff --git a/extension/pybindings/README.md b/extension/pybindings/README.md index 352c7a6f93a..634560b6b0c 100644 --- a/extension/pybindings/README.md +++ b/extension/pybindings/README.md @@ -234,4 +234,45 @@ CMAKE_ARGS="-DEXECUTORCH_BUILD_VULKAN=ON" ./install_executorch.sh This class is currently empty and serves as a placeholder for future methods and attributes. - `verify_result_with_bundled_expected_output(method_name: str, testset_idx: int, rtol: float = 1e-5, atol: float = 1e-8)`: Verify result with bundled expected output. ## Note -Runtime logs are forwarded to Python stderr. +Runtime logs go straight to the process's stderr as they are logged. On Windows, +a console shows text that is not ASCII in its own code page, so such text can +look wrong there. If Python has replaced `sys.stderr`, for example with pytest's +`capsys`, `contextlib.redirect_stderr` or in a Jupyter notebook, the lines go to +`sys.stderr` instead, a little later. The lines a binding call logs wait until +the call returns. A line logged outside any binding call, for example by the LLM +runner, goes to `sys.stderr` before the next Python code runs if Python's main +thread logged it. A line from another thread goes there a moment later, from a +thread that the bindings start for this. Outside a call, only a thread that +holds the GIL can tell where `sys.stderr` goes, so a line from a thread that +does not hold it, such as one the LLM runner logs while it generates, goes to +the process's stderr. The lines wait rather than go to Python at once because a +backend can log while it holds a lock, and writing to Python at that moment +could deadlock with another thread. + +On Linux and macOS, a line also waits once the process's stderr is a pipe or +socket that stays full for 100 ms, so a thread that holds the GIL does not hang +on it while a Python thread in the same process empties it, as under +`wurlitzer.pipes()`. The rest of a binding call's lines then wait for the call +to return. A thread that logs outside a call does not wait for room itself. It +hands the line to the bindings' thread, which writes it without the GIL. While +it writes, a binding call's lines wait for the call to return. A terminal is +written at once, since its reader is normally another process. The process can +still hang if another writer fills the process's stderr right after the check +for room, or if a Python thread in the same process reads a terminal that is the +process's stderr. On macOS, a pipe that has not grown past 16 KiB takes a line +only once the line fits in 512 bytes beside what the pipe holds, because macOS +reports room in such a pipe that it may not have. + +Up to 1 MiB of lines wait at a time; past that, about the newest 512 KiB to +1 MiB are kept, with a note of how many earlier bytes were left out. A fatal +line goes straight to the process's stderr while it has room, with the lines its +binding call kept, since an abort usually follows. Any crash or hang of the +process, including that abort, or an `os._exit()` loses the lines still waiting. +Once Python starts to exit, the lines still waiting are written, and later lines +logged outside a call go to the process's stderr while it has room. Lines for +the process's stderr are left out instead if the bindings' thread is still +writing to it then. + +On Linux with Python before 3.13.8, a daemon thread never writes through Python, +because at exit Python can stop such a thread in the middle of that write, which +crashes the process. The bindings' thread writes its lines instead. diff --git a/extension/pybindings/pybindings.cpp b/extension/pybindings/pybindings.cpp index 9889c9622a8..5ed76f3f626 100644 --- a/extension/pybindings/pybindings.cpp +++ b/extension/pybindings/pybindings.cpp @@ -8,19 +8,29 @@ #include #include +#include #include +#include #include #include #include -#include #include #include #include #include #include +#include #include #include #include +#ifndef _WIN32 +#include +#include +#include +#endif +#ifdef __APPLE__ +#include +#endif #include #include @@ -70,7 +80,7 @@ #endif /// Throws a runtime_error with the provided message if `error` is not `Ok`, -/// followed by the errors the runtime logged during this call. +/// followed by the errors the runtime logged during this call. Needs the GIL. #define THROW_IF_ERROR(error, message, ...) \ ({ \ if ((error) != Error::Ok) { \ @@ -139,9 +149,438 @@ namespace { constexpr size_t kRuntimeErrorsCapacity = 1024; thread_local char runtime_errors[kRuntimeErrorsCapacity]; thread_local size_t runtime_errors_size = 0; -// How many binding calls are running on this thread. Lines logged outside -// any, such as on worker threads, are not kept, so they never linger. -thread_local size_t runtime_error_scopes = 0; +// The lines kept for the current binding call; emit_log_message says when a +// line is kept. Past the capacity the oldest are counted rather than kept, +// since the newest are the closest to a failure. +struct PendingLogs final { + bool direct = false; + bool daemon = false; + std::string lines; + size_t dropped = 0; +}; +constexpr size_t kPendingLogsCapacity = 1024 * 1024; +// Null outside a binding call, so a line logged outside any is never kept here, +// where no call would write it out. A plain pointer for the same reason as +// above. +thread_local PendingLogs* pending_logs = nullptr; + +// Lines that Python's stderr must show but that were logged where Python cannot +// be called: outside any binding call, or on a daemon thread. Python's main +// thread writes the lines it logged itself at its next chance. A thread of +// their own writes the others, since the main thread may be in the middle of a +// sys.stderr.write that holds a lock. Never freed, so that a line logged during +// exit still finds it. +struct DeferredLogs final { + std::mutex mutex; + std::condition_variable writer_changed; + PendingLogs logs; + PendingLogs main_thread_logs; + bool main_thread_write_scheduled = false; + // Numbers the writer's batches, so that a thread can tell whether the lines + // it handed over are written. + uint64_t batch = 1; + uint64_t batches_written = 0; + bool writer_running = false; + // A new thread state crashes a Python that has finished exiting, so exit + // waits while the writer takes the GIL with one. + bool writer_entering = false; + bool exiting = false; +}; + +DeferredLogs*& deferred_logs_state() { + static DeferredLogs* logs = new DeferredLogs(); + return logs; +} + +DeferredLogs& deferred_logs() { + return *deferred_logs_state(); +} + +// The only thread that runs Python's pending calls. +unsigned long main_thread_id = 0; + +// The writer's batch that holds the newest line this thread handed over. +thread_local uint64_t last_deferred_batch = 0; + +void defer_lines(const std::string& lines) noexcept; +bool deferred_lines_wait(); +bool own_deferred_lines_wait(); + +void write_to_stderr(const char* data, size_t size) { + std::fwrite(data, 1, size, stderr); + std::fflush(stderr); +} + +// Writes to fd 2 without C stdio's lock on stderr, so that a write that waits +// for room holds up no thread that flushes stderr, as Python does as it exits. +void write_to_fd_2(const char* data, size_t size) { +#ifdef _WIN32 + write_to_stderr(data, size); +#else + while (size > 0) { + const ssize_t written = ::write(2, data, size); + if (written < 0 && errno == EINTR) { + continue; + } + if (written <= 0) { + return; + } + data += written; + size -= static_cast(written); + } +#endif +} + +// The last stream whose descriptor stderr_is_process_stderr() checked, kept +// alive by this reference, and whether that descriptor is 2. A stream that +// replaced sys.stderr is never kept here. +PyObject* checked_stderr = nullptr; +bool checked_stderr_is_fd_2 = false; + +// Whether sys.stderr is still the stream Python opened on file descriptor 2. +// Runs at import and as a binding call starts, with the GIL held and no lock +// held. Asking a stream for its descriptor costs more than a small binding +// call, so the answer is kept for the last stream checked. +bool stderr_is_process_stderr() noexcept { + PyObject* const current = PySys_GetObject("stderr"); + if (current != nullptr && current == checked_stderr) { + return checked_stderr_is_fd_2; + } + if (current == nullptr || current != PySys_GetObject("__stderr__")) { + return false; + } + Py_INCREF(current); + const bool on_fd_2 = PyObject_AsFileDescriptor(current) == 2; + PyErr_Clear(); + checked_stderr_is_fd_2 = on_fd_2; + Py_XSETREF(checked_stderr, current); + return on_fd_2; +} + +// Whether sys.stderr is still the stream that stderr_is_process_stderr() found +// on fd 2. Runs no Python code, so a thread that logs can ask it. +bool sys_stderr_is_checked_fd_2() noexcept { + return PySys_GetObject("stderr") == checked_stderr && checked_stderr_is_fd_2; +} + +// Whether this thread holds the GIL. PyGILState_Check says yes on every thread +// once a subinterpreter exists, so this compares thread states instead. +bool holds_gil() noexcept { + if (!Py_IsInitialized()) { + return false; + } + PyThreadState* const state = PyGILState_GetThisThreadState(); + return state != nullptr && state == py::detail::get_thread_state_unchecked(); +} + +// Whether this is a daemon thread that a write through Python could crash at +// exit. Before 3.13.8, Python ends a daemon thread with pthread_exit if it then +// waits for the GIL, and on Linux the unwinding runs the C++ destructors on its +// stack, which touch Python objects without the GIL. So such a thread never +// writes its lines through Python. The flag cannot change once the thread runs. +bool on_daemon_thread() { +#ifdef __linux__ + thread_local const bool daemon = Py_Version < 0x030D0800 && + py::module_::import("threading") + .attr("current_thread")() + .attr("daemon") + .cast(); + return daemon; +#else + return false; +#endif +} + +// How long a line waits for room in a full stderr. A reader in another process +// usually catches up within it, while a Python thread in this process never +// does, since it needs the GIL. +constexpr int kStderrRoomWaitMs = 100; + +// Whether a line of `size` bytes can go to the process's stderr, waiting up to +// `wait_ms` for room, so that a thread that holds the GIL never waits on a full +// pipe or socket that a Python thread in this process reads, as +// wurlitzer.pipes() sets up. A terminal is not checked: its reader is another +// process, and a line that waits for it must not be lost to a crash. +bool stderr_has_room(ET_UNUSED size_t size, int wait_ms) { +#ifdef _WIN32 + return true; +#else + if (isatty(2)) { + return true; + } +#ifdef __APPLE__ + // macOS reports room as if a pipe held 16 KiB, but a pipe made while pipe + // memory runs low holds as little as 512 bytes, and a write to it then waits. + // A pipe that grew past 16 KiB reports its real size. Until then, a line goes + // in only once it fits in 512 bytes beside what the pipe holds. + struct stat info; + if (fstat(2, &info) == 0 && S_ISFIFO(info.st_mode) && + info.st_blksize <= 16 * 1024) { + const auto deadline = + std::chrono::steady_clock::now() + std::chrono::milliseconds(wait_ms); + while (static_cast(info.st_size) + size > PIPE_BUF) { + if (std::chrono::steady_clock::now() >= deadline || usleep(1000) != 0 || + fstat(2, &info) != 0) { + return false; + } + } + return true; + } +#endif + pollfd stderr_fd{2, POLLOUT, 0}; + return poll(&stderr_fd, 1, wait_ms) > 0; +#endif +} + +// Drops the oldest half of the kept lines at once when they outgrow the +// capacity, so keeping a line stays cheap. +void keep_line(PendingLogs& logs, const std::string& line) { + logs.lines += line; + if (logs.lines.size() > kPendingLogsCapacity) { + const size_t cut = + logs.lines.find( + '\n', logs.lines.size() - kPendingLogsCapacity / 2 - 1) + + 1; + logs.dropped += cut; + logs.lines.erase(0, cut); + } +} + +void note_left_out_bytes(PendingLogs& logs) noexcept { + if (logs.dropped == 0) { + return; + } + try { + logs.lines.insert( + 0, + "[" + std::to_string(logs.dropped) + + " earlier bytes of the runtime log were left out]\n"); + logs.dropped = 0; + } catch (const std::bad_alloc&) { + } +} + +// Writes lines to fd 2, oldest first, while it has room, and returns how many +// bytes it wrote. Whole lines only, since a pipe that a Python thread drains +// stays full while the GIL is held. +size_t write_lines_while_room(const std::string& lines) noexcept { + size_t begin = 0; + while (begin < lines.size()) { + const size_t end = lines.find('\n', begin) + 1; + if (!stderr_has_room(end - begin, kStderrRoomWaitMs)) { + break; + } + write_to_stderr(lines.data() + begin, end - begin); + begin = end; + } + return begin; +} + +// Writes the kept lines to fd 2 while it has room, and keeps the rest. +void write_kept_lines_while_room(PendingLogs& logs) noexcept { + logs.lines.erase(0, write_lines_while_room(logs.lines)); +} + +// Runs as the outermost binding call returns, or at exit, with the GIL held and +// no backend lock or object lock held. An exception may be unwinding, so it +// never throws. +void write_logs_to_python(PendingLogs& logs) noexcept { + note_left_out_bytes(logs); + if (logs.lines.empty()) { + return; + } + // A call's lines go after the lines this thread handed to the writer thread. + if (logs.daemon || own_deferred_lines_wait()) { + defer_lines(logs.lines); + return; + } + try { + // A line that is not valid UTF-8, such as one that prints raw bytes from a + // model file, must not keep the rest from Python. + const auto text = py::reinterpret_steal(PyUnicode_DecodeUTF8( + logs.lines.data(), logs.lines.size(), "backslashreplace")); + if (!text) { + throw py::error_already_set(); + } + py::module_::import("sys").attr("stderr").attr("write")(text); + return; + } catch (py::error_already_set& error) { + // Python runs a Ctrl-C's handler in the first Python code after it, which + // can be this write. Raise it again once the call has returned. + if (error.matches(PyExc_KeyboardInterrupt)) { + PyErr_SetInterrupt(); + } + } catch (...) { + } + // A write that was cut short may have shown some lines already, but + // repeating them is better than losing them. stderr can be a pipe that a + // Python thread drains, as in Jupyter, so the GIL is released. + py::gil_scoped_release release; + write_to_stderr(logs.lines.data(), logs.lines.size()); +} + +// Runs on Python's main thread between two bytecodes, with the GIL held. +int write_main_thread_logs(void*) noexcept { + PendingLogs logs; + { + std::lock_guard lock(deferred_logs().mutex); + deferred_logs().main_thread_write_scheduled = false; + std::swap(logs, deferred_logs().main_thread_logs); + } + write_logs_to_python(logs); + return 0; +} + +// Writes through Python's C API only, and is not noexcept, since Python may end +// this thread while it waits for the GIL during exit, and the unwinding must +// neither touch a Python object nor stop at a noexcept frame. +void write_lines_through_python(const PendingLogs& logs) { + PyObject* const text = PyUnicode_DecodeUTF8( + logs.lines.data(), logs.lines.size(), "backslashreplace"); + PyObject* const stream = PySys_GetObject("stderr"); + PyObject* const result = text == nullptr || stream == nullptr + ? nullptr + : PyObject_CallMethod(stream, "write", "O", text); + const bool written = result != nullptr; + Py_XDECREF(result); + Py_XDECREF(text); + if (!written) { + PyErr_Clear(); + PyThreadState* const state = PyEval_SaveThread(); + write_to_fd_2(logs.lines.data(), logs.lines.size()); + PyEval_RestoreThread(state); + } +} + +// Runs on a thread of its own until no line waits. +void write_deferred_logs() { + DeferredLogs& deferred = deferred_logs(); + for (;;) { + PendingLogs logs; + uint64_t batch = 0; + { + std::lock_guard lock(deferred.mutex); + if (deferred.exiting || deferred.logs.lines.empty()) { + deferred.writer_running = false; + deferred.writer_changed.notify_all(); + return; + } + std::swap(logs, deferred.logs); + batch = deferred.batch++; + deferred.writer_entering = true; + } + const PyGILState_STATE gil = PyGILState_Ensure(); + { + std::lock_guard lock(deferred.mutex); + deferred.writer_entering = false; + deferred.writer_changed.notify_all(); + } + note_left_out_bytes(logs); + if (stderr_is_process_stderr()) { + // Waits for room without the GIL or any lock of Python's, so that a full + // fd 2 never holds up Python's exit. + PyThreadState* const state = PyEval_SaveThread(); + write_to_fd_2(logs.lines.data(), logs.lines.size()); + PyEval_RestoreThread(state); + } else { + write_lines_through_python(logs); + } + PyGILState_Release(gil); + std::lock_guard lock(deferred.mutex); + deferred.batches_written = batch; + } +} + +// Runs with the GIL held. +void defer_lines(const std::string& lines) noexcept { + DeferredLogs& deferred = deferred_logs(); + std::lock_guard lock(deferred.mutex); + if (deferred.exiting) { + // Python's main thread may need the GIL to finish exiting, so the lines go + // out only while fd 2 has room, and not at all while a writer that is still + // writing can take that room first. + if (!deferred.writer_running) { + write_lines_while_room(lines); + } + return; + } + try { + // The main thread logged these lines from native code, which is inside a + // sys.stderr.write only if that write runs such code. So it can write them + // before its next bytecode, while a short redirect of sys.stderr still + // lasts. Its earlier lines stay ahead. + if (PyThread_get_thread_ident() == main_thread_id && + (deferred.main_thread_write_scheduled || + !sys_stderr_is_checked_fd_2())) { + keep_line(deferred.main_thread_logs, lines); + if (!deferred.main_thread_write_scheduled) { + deferred.main_thread_write_scheduled = + Py_AddPendingCall(&write_main_thread_logs, nullptr) == 0; + } + return; + } + keep_line(deferred.logs, lines); + last_deferred_batch = deferred.batch; + if (!deferred.writer_running) { + std::thread(write_deferred_logs).detach(); + deferred.writer_running = true; + } + } catch (const std::exception&) { + // Lines that no thread could take wait for the exit hook. + } +} + +// Whether lines wait for the writer thread or for Python's main thread. While +// the writer writes to fd 2, the room that a check sees can be gone. +bool deferred_lines_wait() { + DeferredLogs& deferred = deferred_logs(); + std::lock_guard lock(deferred.mutex); + return (deferred.writer_running || deferred.main_thread_write_scheduled) && + !deferred.exiting; +} + +bool own_deferred_lines_wait() { + DeferredLogs& deferred = deferred_logs(); + std::lock_guard lock(deferred.mutex); + return last_deferred_batch > deferred.batches_written && !deferred.exiting; +} + +// Python stops other threads once exit begins, and later its state is gone, so +// the waiting lines go out now and later ones go to fd 2 while it has room. A +// writer that is taking the GIL gets it first, and one that is writing gets a +// moment to finish, so that its stream is free for Python's last flush. +void write_deferred_logs_at_exit() noexcept { + DeferredLogs& deferred = deferred_logs(); + PendingLogs main_thread_logs; + PendingLogs logs; + bool writer_running = false; + { + py::gil_scoped_release release; + std::unique_lock lock(deferred.mutex); + deferred.exiting = true; + std::swap(main_thread_logs, deferred.main_thread_logs); + std::swap(logs, deferred.logs); + deferred.writer_changed.wait( + lock, [&deferred] { return !deferred.writer_entering; }); + deferred.writer_changed.wait_for( + lock, std::chrono::milliseconds(kStderrRoomWaitMs), [&deferred] { + return !deferred.writer_running; + }); + writer_running = deferred.writer_running; + } + for (PendingLogs* waiting : {&main_thread_logs, &logs}) { + if (stderr_is_process_stderr()) { + // No Python thread may be left to read a full fd 2. A writer still + // writing means fd 2 stayed full, and it could take any room seen here. + if (!writer_running) { + note_left_out_bytes(*waiting); + write_kept_lines_while_room(*waiting); + } + } else { + write_logs_to_python(*waiting); + } + } +} std::string with_runtime_errors(const char* message) { std::string result(message); @@ -149,24 +588,43 @@ std::string with_runtime_errors(const char* message) { result.append(": ").append(runtime_errors, runtime_errors_size); runtime_errors_size = 0; } - return result; + // Python rejects an exception message that is not valid UTF-8. The message is + // cut at a fixed length, which can split a character of a path, and the + // runtime can log raw bytes, such as a file's identifier. + const auto text = py::reinterpret_steal( + PyUnicode_DecodeUTF8(result.data(), result.size(), "backslashreplace")); + if (!text) { + throw py::error_already_set(); + } + return text.cast(); } // Starts every binding call with no runtime errors recorded, and gives an -// outer call its own back when a nested call returns. +// outer call its own back when a nested call returns. The outermost call owns +// the lines kept during it and writes them out as it returns. They live on the +// heap, so a pointer to them stays valid if a callback switches greenlets. struct RuntimeErrorScope final { RuntimeErrorScope() : outer_(runtime_errors, runtime_errors_size) { runtime_errors_size = 0; - ++runtime_error_scopes; + if (pending_logs == nullptr) { + logs_ = std::make_unique(); + logs_->daemon = on_daemon_thread(); + logs_->direct = stderr_is_process_stderr(); + pending_logs = logs_.get(); + } } ~RuntimeErrorScope() { - --runtime_error_scopes; std::memcpy(runtime_errors, outer_.data(), outer_.size()); runtime_errors_size = outer_.size(); + if (logs_ != nullptr) { + pending_logs = nullptr; + write_logs_to_python(*logs_); + } } private: std::string outer_; + std::unique_ptr logs_; }; #ifndef USE_ATEN_LIB @@ -4891,11 +5349,31 @@ py::dict get_option(const std::string& backend_name, const py::dict& options) { } // namespace PYBIND11_MODULE(EXECUTORCH_PYTHON_MODULE_NAME, m) { - // Gives each call its own record of runtime errors for its exceptions. - // Runtime logs reach Python through emit_log_message below rather than + // Gives each call its own record of runtime errors for its exceptions, and + // decides where the call's runtime logs go. This replaces // scoped_ostream_redirect, which mutates process-wide stream buffers and is // not thread-safe when Python operations inside a binding release the GIL. + // It calls Python as it starts and may write to Python as it ends, so it + // needs the GIL and never follows a py::gil_scoped_release guard. auto call_guard = py::call_guard(); + // A line logged outside any call compares sys.stderr with the stream checked + // last, so the first check cannot wait for the first call. + stderr_is_process_stderr(); + main_thread_id = py::module_::import("threading") + .attr("main_thread")() + .attr("ident") + .cast(); + py::module_::import("atexit").attr("register")( + py::cpp_function([]() { write_deferred_logs_at_exit(); })); +#ifndef _WIN32 + // A child of fork() has no writer thread, the waiting lines are the + // parent's, and the thread that forked is its main thread. + pthread_atfork(nullptr, nullptr, [] { + deferred_logs_state() = new DeferredLogs(); + main_thread_id = PyThread_get_thread_ident(); + last_deferred_batch = 0; + }); +#endif #if defined(EXECUTORCH_PYBINDINGS_CUDA) && !defined(USE_ATEN_LIB) // Back executorch.runtime.cuda, which keeps the runtime free of PyTorch. @@ -5320,8 +5798,28 @@ PYBIND11_MODULE(EXECUTORCH_PYTHON_MODULE_NAME, m) { namespace { -// Forward runtime logs to Python without mutating std::cerr's process-wide -// stream buffer. +// Whether a line logged outside any binding call waits. Only a thread that +// holds the GIL can see where sys.stderr goes. Such a thread hands the line +// over when sys.stderr goes elsewhere, when fd 2 has no room, since the GIL it +// holds may be what a thread draining it needs, or when earlier lines still +// wait. Any other thread writes to fd 2. +bool line_outside_a_call_waits(size_t size) { + return holds_gil() && + (!sys_stderr_is_checked_fd_2() || deferred_lines_wait() || + !stderr_has_room(size, 0)); +} + +// Records the runtime's errors for exceptions and routes each line without +// calling Python: a backend can log while it holds its own lock, and a Python +// write there can hand the GIL to a thread that then waits for that lock. A +// line goes to the process's stderr through C stdio, since another module can +// redirect std::cerr into Python. When Python has replaced sys.stderr, as a +// notebook does, a line logged during a binding call waits for the call to +// return, and a line logged outside any call by a thread that holds the GIL +// waits for Python's main thread, if it logged the line, or for the writer +// thread. Such lines also wait once fd 2 is a pipe or socket that stays full. +// Even then a fatal line goes out at once while fd 2 has room, with the lines +// its call kept, since an abort usually follows. void emit_log_message( ET_UNUSED et_timestamp_t timestamp, et_pal_log_level_t level, @@ -5332,7 +5830,7 @@ void emit_log_message( size_t length) { const std::string formatted_message = "[" + std::string(filename) + ":" + std::to_string(line) + "] " + std::string(message, length) + "\n"; - if (level == kError && runtime_error_scopes != 0) { + if (level == kError && pending_logs != nullptr) { // Keeps the first lines, which usually name the cause. const char separator[] = "; "; const size_t prefix = runtime_errors_size == 0 ? 0 : sizeof(separator) - 1; @@ -5345,17 +5843,34 @@ void emit_log_message( runtime_errors_size += prefix + copied; } } - if (Py_IsInitialized() && PyGILState_Check()) { - try { - py::gil_scoped_acquire gil; - py::module_::import("sys").attr("stderr").attr("write")( - formatted_message); - return; - } catch (py::error_already_set& error) { - error.clear(); + if (pending_logs == nullptr) { + const size_t size = formatted_message.size(); + if (level == kFatal + ? holds_gil() && !stderr_has_room(size, kStderrRoomWaitMs) + : line_outside_a_call_waits(size)) { + defer_lines(formatted_message); + } else { + write_to_stderr(formatted_message.data(), size); } + return; + } + // While the writer thread writes to fd 2, it can take the room seen here + // before this write. + if (pending_logs->direct && !deferred_lines_wait() && + stderr_has_room(formatted_message.size(), kStderrRoomWaitMs)) { + write_to_stderr(formatted_message.data(), formatted_message.size()); + return; + } + // The rest of the call's lines wait for it to return. Then they go through + // Python, which lets go of the GIL while it waits for room. A daemon thread + // leaves them to the writer thread. + pending_logs->direct = false; + keep_line(*pending_logs, formatted_message); + if (level == kFatal) { + // An abort usually follows, and the waiting lines tell what led to it. + note_left_out_bytes(*pending_logs); + write_kept_lines_while_room(*pending_logs); } - std::cerr << formatted_message << std::flush; } runtime::PalImpl build_pal() { diff --git a/extension/pybindings/test/BUCK b/extension/pybindings/test/BUCK index a06a66c4a29..0602bfc2de2 100644 --- a/extension/pybindings/test/BUCK +++ b/extension/pybindings/test/BUCK @@ -40,6 +40,7 @@ fbcode_target( preload_deps = ["//executorch/kernels/quantized:aot_lib"], deps = [ ":make_test", + "//executorch/backends/xnnpack/partition:xnnpack_partitioner", "//executorch/exir:schema", "//executorch/exir/backend/test:device_util", "//executorch/extension/pybindings:portable_lib", @@ -76,6 +77,7 @@ fbcode_target( srcs = ["test_pybindings.py"], deps = [ ":make_test", + "//executorch/backends/xnnpack/partition:xnnpack_partitioner", "//executorch/exir:schema", "//executorch/exir/backend/test:device_util", ], diff --git a/extension/pybindings/test/test_pybindings.py b/extension/pybindings/test/test_pybindings.py index 1b661995501..b212abafdc8 100644 --- a/extension/pybindings/test/test_pybindings.py +++ b/extension/pybindings/test/test_pybindings.py @@ -8,10 +8,13 @@ import ctypes import gc +import json import os +import select import subprocess import sys import tempfile +import time import unittest import weakref from io import StringIO @@ -52,6 +55,22 @@ def __dlpack_device__(self): return self.array.__dlpack_device__() +def write_llm_tokenizer(directory: str) -> str: + """Writes a tokenizer that the LLM runner loads without printing anything, + so that its only lines come through the runtime, outside any call of these + bindings, while it finds that the model next to it is missing.""" + path = os.path.join(directory, "tokenizer.json") + with open(path, "w") as file: + json.dump( + { + "added_tokens": [], + "model": {"type": "BPE", "vocab": {"a": 0, "b": 1}, "merges": []}, + }, + file, + ) + return path + + class PybindingsTest(unittest.TestCase): def setUp(self): # Will test both portable and aten @@ -377,6 +396,799 @@ def run(executor, separate_calls, locked=True): ) self.assertIn("all calls finished", completed.stdout) + def test_backend_logs_do_not_deadlock_other_threads(self): + if self.kernel_mode != "portable": + self.skipTest("the script below uses the portable bindings") + if "XnnpackBackend" not in self.runtime._get_registered_backend_names(): + self.skipTest("needs the XNNPACK backend") + + script = """ +import os +import sys +import threading +import time +from concurrent.futures import ThreadPoolExecutor + +import torch +from executorch.backends.xnnpack.partition.xnnpack_partitioner import ( + XnnpackPartitioner, +) +from executorch.exir import to_edge_transform_and_lower +from executorch.exir._serialize._program import PTEFile, serialize_pte_binary +from executorch.exir.schema import ( + BackendDelegateDataReference, + BackendDelegateInlineData, + DataLocation, +) +from executorch.extension.pybindings import portable_lib + + +class Add(torch.nn.Module): + def forward(self, x, y): + return x + y + + +inputs = [torch.ones(2, 2), torch.ones(2, 2)] +exported = torch.export.export(Add(), tuple(inputs)) + + +def broken_program(methods): + # XNNPACK logs a broken payload while it holds its workspace lock, which + # the global sharing mode shares between all methods. + schema = to_edge_transform_and_lower( + methods, partitioner=[XnnpackPartitioner()] + ).to_executorch().executorch_program + schema.execution_plan[0].delegates[0].processed = BackendDelegateDataReference( + location=DataLocation.INLINE, index=len(schema.backend_delegate_data) + ) + schema.backend_delegate_data.append(BackendDelegateInlineData(data=b"\\x01" * 64)) + return bytes(serialize_pte_binary(PTEFile(schema))) + + +data = to_edge_transform_and_lower( + exported, partitioner=[XnnpackPartitioner()] +).to_executorch().buffer +if sys.argv[1] == "LLM runner": + try: + from executorch.extension.llm.runner import TextLLMRunner + except (ImportError, RuntimeError): + # The package raises RuntimeError when its extension is not built. + print("no LLM runner") + sys.exit() + # The LLM runner reads this method as it starts, outside any call of these + # bindings, on a thread that holds the GIL. + model = os.path.join(os.path.dirname(sys.argv[2]), "broken.pte") + with open(model, "wb") as file: + file.write(broken_program({"get_max_seq_len": exported})) +else: + broken = portable_lib._load_program_from_buffer(broken_program(exported)) +portable_lib._set_option("XnnpackBackend", {"workspace_sharing_mode": 2}) +method = portable_lib._load_program_from_buffer(data).load_method("forward") + +running = threading.Event() +done = threading.Event() + + +def run(): + while not done.is_set(): + method(inputs) + running.set() + + +class SlowStderr: + # A real write releases the GIL for its system call. Keep that window open + # so the other thread surely runs inside it. + def write(self, text): + time.sleep(0.5) + return len(text) + + def flush(self): + pass + + def fileno(self): + return 2 + + +message = "" +process_stderr = sys.__stderr__ +with ThreadPoolExecutor(max_workers=1) as pool: + worker = pool.submit(run) + while not running.wait(timeout=0.1): + if worker.done(): + worker.result() + sys.stderr = SlowStderr() + if sys.argv[1] == "process": + # Stands in for the stream Python opened on fd 2, the default case. + sys.__stderr__ = sys.stderr + try: + if sys.argv[1] == "LLM runner": + TextLLMRunner(model, sys.argv[2]) + else: + broken.load_method("forward") + except RuntimeError as error: + message = str(error) + finally: + sys.stderr = sys.__stderr__ = process_stderr + done.set() + worker.result() +# XNNPACK logged this while it held its workspace lock. +assert sys.argv[1] == "LLM runner" or "XNNCompiler::compileModel failed" in message, message +print("no deadlock") +""" + with tempfile.TemporaryDirectory() as directory: + tokenizer = write_llm_tokenizer(directory) + for mode in ("replaced", "process", "LLM runner"): + completed = subprocess.run( + [sys.executable, "-c", script, mode, tokenizer], + capture_output=True, + text=True, + timeout=120, + ) + self.assertEqual( + completed.returncode, + 0, + f"{mode}:\nstdout:\n{completed.stdout}\nstderr:\n{completed.stderr}", + ) + if "no LLM runner" not in completed.stdout: + self.assertIn("no deadlock", completed.stdout) + + def test_daemon_threads_that_log_do_not_crash_at_exit(self): + if self.kernel_mode != "portable": + self.skipTest("the script below uses the portable bindings") + if not sys.platform.startswith("linux") or sys.version_info >= (3, 13, 8): + self.skipTest("the crash needs Linux and Python before 3.13.8") + + script = """ +import atexit +import os +import sys +import threading +import time + +# Registered before the import, so it runs after the bindings' own exit hook, +# and the daemon threads log while it sleeps. +atexit.register(time.sleep, 0.1) + +from executorch.extension.pybindings import portable_lib + + +# The daemon threads below. Python calls any thread it did not start a daemon. +spinners = set() + + +class Sink: + daemon_writes = 0 + text = [] + + def write(self, text): + if threading.get_ident() in spinners: + Sink.daemon_writes += 1 + Sink.text.append(text) + return len(text) + + def flush(self): + pass + + +def spin(): + spinners.add(threading.get_ident()) + while True: + try: + portable_lib._load_program_from_buffer(b"x" * 128) + except RuntimeError: + pass + + +if "full" in sys.argv[1]: + # Nobody reads this pipe, so the lines that find it full are dropped. + read_end, write_end = os.pipe() + os.dup2(write_end, 2) + os.set_blocking(2, False) + try: + while True: + os.write(2, b"x" * 4096) + except BlockingIOError: + pass + os.set_blocking(2, True) +if "replaced" in sys.argv[1]: + sys.stderr = Sink() +for _ in range(2): + threading.Thread(target=spin, daemon=True).start() +time.sleep(0.3) +print("writes from daemon threads:", Sink.daemon_writes) +print("lines through Python:", "".join(Sink.text).count("Program identifier")) +""" + for mode in ("replaced", "full", "full and replaced"): + completed = subprocess.run( + [sys.executable, "-c", script, mode], + capture_output=True, + text=True, + timeout=120, + ) + self.assertEqual( + completed.returncode, 0, f"{mode}: {completed.stderr[-2000:]}" + ) + self.assertIn("writes from daemon threads: 0", completed.stdout) + if "replaced" in mode: + # Another thread wrote them, as a notebook shows only Python's + # stderr. + self.assertNotIn("lines through Python: 0", completed.stdout) + if mode == "replaced": + # The lines logged after the bindings' exit hook go to fd 2. + self.assertIn("Program identifier", completed.stderr) + + def test_runtime_logs_outside_a_call_reach_sys_stderr(self): + if self.kernel_mode != "portable": + self.skipTest("the script below uses the portable bindings") + + script = """ +import contextlib +import io +import os +import sys +import threading +import time + +captured = io.StringIO() +if sys.argv[1] == "replaced before import": + # As in a notebook, which replaces sys.stderr before any import. + sys.stderr = captured +from executorch.extension.pybindings import portable_lib + +try: + from executorch.extension.llm.runner import TextLLMRunner +except (ImportError, RuntimeError): + # The package raises RuntimeError when its extension is not built. + print("no LLM runner") + sys.exit() +missing_program = os.path.join(os.path.dirname(sys.argv[2]), "missing.pte") + +if sys.argv[1] == "replaced, another thread": + sys.stderr = captured + + def log_twice(): + try: + TextLLMRunner(missing_program, sys.argv[2]) + except RuntimeError: + pass + try: + portable_lib._load_program_from_buffer(b"x" * 128) + except RuntimeError: + pass + + worker = threading.Thread(target=log_twice) + worker.start() + worker.join() + # A thread of the bindings writes the LLM runner's lines a moment later, and + # the call's line must follow them. + deadline = time.monotonic() + 10 + while ( + "Program identifier" not in captured.getvalue() + and time.monotonic() < deadline + ): + time.sleep(0.01) + sys.stderr = sys.__stderr__ + text = captured.getvalue() + first = text.find("[llm_runner_helper.cpp:") + print("in order:", 0 <= first < text.find("Program identifier")) + sys.exit() + +with ( + contextlib.redirect_stderr(captured) + if sys.argv[1] == "replaced after import" + else contextlib.nullcontext() +): + try: + TextLLMRunner(missing_program, sys.argv[2]) + except RuntimeError: + if sys.argv[1] == "process stderr": + # Ends the process before Python could write lines that waited. + os._exit(0) + # Python's main thread writes the lines it logged before its next bytecode, + # so they reach a redirect that ends right after the call. +sys.stderr = sys.__stderr__ +print("lines through Python:", captured.getvalue().count("[llm_runner_helper.cpp:")) +""" + with tempfile.TemporaryDirectory() as directory: + tokenizer = write_llm_tokenizer(directory) + for mode in ( + "replaced before import", + "replaced after import", + "replaced, another thread", + "process stderr", + ): + completed = subprocess.run( + [sys.executable, "-c", script, mode, tokenizer], + capture_output=True, + text=True, + timeout=120, + ) + self.assertEqual( + completed.returncode, 0, f"{mode}: {completed.stderr[-2000:]}" + ) + if "no LLM runner" in completed.stdout: + self.skipTest("the LLM runner bindings are not built") + if mode == "process stderr": + self.assertIn("[llm_runner_helper.cpp:", completed.stderr) + elif mode == "replaced, another thread": + self.assertIn("in order: True", completed.stdout) + else: + self.assertIn("lines through Python:", completed.stdout, mode) + self.assertNotIn("lines through Python: 0", completed.stdout, mode) + + def test_runtime_logs_when_writing_to_stderr_fails(self): + if self.kernel_mode != "portable": + self.skipTest("the script below uses the portable bindings") + + script = """ +import signal +import sys + +from executorch.extension.pybindings import portable_lib + +# Python installs no SIGINT handler when SIGINT starts ignored, as in a +# background job, and then a Ctrl-C cannot be raised again. +signal.signal(signal.SIGINT, signal.default_int_handler) + + +class FailingStderr: + def __init__(self, error): + self.error = error + + def write(self, text): + raise self.error + + def flush(self): + pass + + +def load_broken_program(): + try: + portable_lib._load_program_from_buffer(b"x" * 128) + except RuntimeError: + pass + + +# A call made while sys.stderr is the process's own goes first, so that +# replacing sys.stderr afterwards must still be noticed. +load_broken_program() + +# A write that fails sends the lines to the process's stderr instead. +sys.stderr = FailingStderr(ValueError("closed")) +try: + load_broken_program() +finally: + sys.stderr = sys.__stderr__ + +# An interrupt raised in the write still reaches the caller. +interrupted = False +sys.stderr = FailingStderr(KeyboardInterrupt()) +try: + load_broken_program() + for _ in range(1000): + pass +except KeyboardInterrupt: + interrupted = True +finally: + sys.stderr = sys.__stderr__ +assert interrupted +print("done") +""" + completed = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + timeout=120, + ) + + self.assertEqual( + completed.returncode, + 0, + f"stdout:\n{completed.stdout}\nstderr:\n{completed.stderr}", + ) + self.assertIn("done", completed.stdout) + # The first load writes straight to the process's stderr. Neither + # write showed the other loads' lines, so they reach it too. + self.assertEqual( + completed.stderr.count("Program identifier 'xxxx'"), 3, completed.stderr + ) + + def test_fatal_runtime_log_reaches_process_stderr(self): + if self.kernel_mode != "portable": + self.skipTest("the script below uses the portable bindings") + if sys.platform == "win32": + self.skipTest("an abort can start Windows Error Reporting") + + exported_program, _ = create_program(ModuleAddSingleInput()) + script = """ +import io +import sys + +import torch +from executorch.extension.pybindings import portable_lib + +# Logging the first output to a debug buffer this small fails, and the runtime +# reports that with a fatal line before it aborts. +module = portable_lib._load_for_executorch( + sys.argv[1], enable_etdump=True, debug_buffer_size=8 +) +sys.stderr = io.StringIO() +module.forward([torch.ones(2, 2)]) +""" + with tempfile.TemporaryDirectory() as directory: + path = os.path.join(directory, "add.pte") + with open(path, "wb") as file: + file.write(exported_program.buffer) + completed = subprocess.run( + [sys.executable, "-c", script, path], + capture_output=True, + text=True, + timeout=120, + ) + + self.assertNotEqual(completed.returncode, 0, completed.stderr) + # The abort skips the end of the call, where kept lines go to the + # replaced sys.stderr, so the fatal line went out at once, after the + # line the call kept before it. + kept = completed.stderr.find("Ran out of space to store intermediate outputs") + fatal = completed.stderr.find("failed to write tensor to debug buffer") + self.assertNotEqual(kept, -1, completed.stderr) + self.assertGreater(fatal, kept, completed.stderr) + + def test_runtime_logs_reach_process_stderr_at_once(self): + if self.kernel_mode != "portable": + self.skipTest("the script below uses the portable bindings") + if sys.platform == "win32": + self.skipTest("uses a pseudo-terminal") + + exported_program, _ = create_program(ModuleAddSingleInput()) + script = """ +import os +import sys + +import numpy as np +from executorch.extension.pybindings import portable_lib + + +class DiesDuringTheCall: + # Converting this input fills the terminal, logs from inside the call, and + # ends the process before the call returns. + def __dlpack__(self, stream=None): + os.set_blocking(2, False) + try: + while True: + os.write(2, b"." * 64) + except BlockingIOError: + pass + os.set_blocking(2, True) + print("filled", flush=True) + try: + portable_lib._load_program_from_buffer(b"x" * 128) + except RuntimeError: + pass + os.write(2, b"still in the call\\n") + os._exit(3) + + def __dlpack_device__(self): + return np.ones((2, 2), dtype=np.float32).__dlpack_device__() + + +with open(sys.argv[1], "rb") as file: + module = portable_lib._load_for_executorch_from_buffer(file.read()) +module(DiesDuringTheCall()) +""" + try: + terminal, child_end = os.openpty() + except OSError as error: + self.skipTest(f"no pseudo-terminal: {error}") + with tempfile.TemporaryDirectory() as directory: + path = os.path.join(directory, "add.pte") + with open(path, "wb") as file: + file.write(exported_program.buffer) + child = subprocess.Popen( + [sys.executable, "-c", script, path], + stdout=subprocess.PIPE, + stderr=child_end, + text=True, + ) + os.close(child_end) + # Like a busy terminal, start reading a little after the child has + # filled it and logged. + filled = ( + child.stdout.readline() + if select.select([child.stdout], [], [], 120)[0] + else "" + ) + time.sleep(0.5) + output = b"" + while select.select([terminal], [], [], 120)[0]: + try: + chunk = os.read(terminal, 65536) + except OSError: # Linux reports the closed other end as EIO. + break + if not chunk: + break + output += chunk + child.kill() + child.wait() + child.stdout.close() + os.close(terminal) + + text = output.decode(errors="replace") + self.assertEqual(filled, "filled\n", text) + self.assertEqual(child.returncode, 3, text) + # sys.stderr is the process's own stderr there, so the line went out as + # it was logged rather than with the call that never returned. + self.assertIn("Program identifier 'xxxx'", text) + self.assertLess( + text.index("Program identifier 'xxxx'"), text.index("still in the call") + ) + + def test_full_stderr_drained_by_a_thread_does_not_block_a_call(self): + if self.kernel_mode != "portable": + self.skipTest("the script below uses the portable bindings") + if sys.platform == "win32": + self.skipTest("uses POSIX pipes and sockets as fd 2") + + script = """ +import os +import socket +import sys +import threading +import time + +from executorch.extension.pybindings import portable_lib + +if "LLM runner" in sys.argv[1]: + try: + from executorch.extension.llm.runner import TextLLMRunner + except (ImportError, RuntimeError): + # The package raises RuntimeError when its extension is not built. + print("no LLM runner") + sys.exit() +missing_program = os.path.join(os.path.dirname(sys.argv[3]), "missing.pte") +if sys.argv[2] == "socket": + read_socket, write_socket = socket.socketpair() + read_end, write_end = read_socket.fileno(), write_socket.fileno() +else: + read_end, write_end = os.pipe() +# Fill it, so that a write that waits for room needs the reader below. +os.set_blocking(write_end, False) +for size in (4096, 512, 64, 1): + try: + while True: + os.write(write_end, b"x" * size) + except BlockingIOError: + pass +os.set_blocking(write_end, True) +saved_stderr = os.dup(2) +os.dup2(write_end, 2) +drained = bytearray() + + +def drain(): + # Like a capture thread, this needs the GIL after every read. It starts + # after the call below has begun. + time.sleep(0.2) + while True: + drained.extend(os.read(read_end, 4096)) + + +threading.Thread(target=drain, daemon=True).start() +try: + if sys.argv[1] == "LLM runner": + # It holds the GIL and logs outside any call of these bindings. + try: + TextLLMRunner(missing_program, sys.argv[3]) + except RuntimeError: + pass + expected = b"[llm_runner_helper.cpp:" + elif sys.argv[1] == "LLM runner and binding calls": + # The LLM runner's lines wait for a thread of the bindings, which writes + # them to fd 2 while the calls check it for room. + stop = threading.Event() + + def build_runners(): + while not stop.is_set(): + try: + TextLLMRunner(missing_program, sys.argv[3]) + except RuntimeError: + pass + + builder = threading.Thread(target=build_runners) + builder.start() + end = time.monotonic() + 10 + while time.monotonic() < end: + try: + portable_lib._load_program_from_buffer(b"x" * 128) + except RuntimeError: + pass + stop.set() + builder.join() + expected = b"Program identifier" + else: + try: + portable_lib._load_program_from_buffer(b"x" * 128) + except RuntimeError: + pass + expected = b"Program identifier" + while expected not in drained: + time.sleep(0.01) +finally: + # Lets an error here reach the test's stderr rather than fd 2. + os.dup2(saved_stderr, 2) +print("done") +""" + with tempfile.TemporaryDirectory() as directory: + tokenizer = write_llm_tokenizer(directory) + for kind in ("pipe", "socket"): + for mode in ( + "binding call", + "LLM runner", + "LLM runner and binding calls", + ): + completed = subprocess.run( + [sys.executable, "-c", script, mode, kind, tokenizer], + capture_output=True, + text=True, + timeout=120, + ) + self.assertEqual( + completed.returncode, + 0, + f"{mode}, {kind}:\nstdout:\n{completed.stdout}\n" + f"stderr:\n{completed.stderr}", + ) + if "no LLM runner" not in completed.stdout: + self.assertIn("done", completed.stdout, f"{mode}, {kind}") + + def test_deferred_runtime_logs_do_not_nest_in_a_stderr_write(self): + if self.kernel_mode != "portable": + self.skipTest("the script below uses the portable bindings") + + script = """ +import os +import queue +import sys +import threading +import time + +from executorch.extension.pybindings import portable_lib + +if sys.argv[1] == "LLM runner": + try: + from executorch.extension.llm.runner import TextLLMRunner + except (ImportError, RuntimeError): + # The package raises RuntimeError when its extension is not built. + print("no LLM runner") + sys.exit() + expected = "[llm_runner_helper.cpp:" +else: + expected = "Program identifier" + + +class QueuedStderr: + # Like a logging queue, its write runs Python code while it holds a lock. + def __init__(self): + self.texts = queue.Queue() + + def write(self, text): + self.texts.put(text) + return len(text) + + def flush(self): + pass + + def text(self): + return "".join(self.texts.queue) + + +def log(): + while not done.is_set(): + if sys.argv[1] == "LLM runner": + # It holds the GIL and logs outside any call of these bindings. + try: + TextLLMRunner( + os.path.join(os.path.dirname(sys.argv[2]), "missing.pte"), + sys.argv[2], + ) + except RuntimeError: + pass + else: + try: + portable_lib._load_program_from_buffer(b"x" * 128) + except RuntimeError: + pass + started.set() + + +done, started = threading.Event(), threading.Event() +stream = QueuedStderr() +sys.stderr = stream +thread = threading.Thread(target=log, daemon=sys.argv[1] == "daemon thread") +thread.start() +started.wait(60) +# The other thread's lines must never be written from inside these writes. +deadline = time.monotonic() + 2 +while time.monotonic() < deadline: + print("main thread", file=sys.stderr) +done.set() +thread.join() +deadline = time.monotonic() + 10 +while expected not in stream.text() and time.monotonic() < deadline: + time.sleep(0.01) +sys.stderr = sys.__stderr__ +print("lines through Python:", stream.text().count(expected)) +""" + with tempfile.TemporaryDirectory() as directory: + tokenizer = write_llm_tokenizer(directory) + for mode in ("LLM runner", "daemon thread"): + completed = subprocess.run( + [sys.executable, "-c", script, mode, tokenizer], + capture_output=True, + text=True, + timeout=120, + ) + self.assertEqual( + completed.returncode, 0, f"{mode}: {completed.stderr[-2000:]}" + ) + if "no LLM runner" not in completed.stdout: + self.assertIn("lines through Python:", completed.stdout, mode) + self.assertNotIn("lines through Python: 0", completed.stdout, mode) + + def test_long_call_keeps_its_newest_runtime_logs(self): + if self.kernel_mode != "portable": + self.skipTest("only the portable bindings take DLPack inputs") + + exported_program, _ = create_program(ModuleAddSingleInput()) + module = self.load_fn(exported_program.buffer) + runtime = self.runtime + values = np.ones((2, 2), dtype=np.float32) + + class LogsDuringTheCall: + # Converting this input logs far more than one call keeps, ending + # with a line that is not valid UTF-8. + def __dlpack__(self, stream=None): + for _ in range(20000): + try: + runtime._load_program_from_buffer(b"x" * 128) + except RuntimeError: + pass + try: + runtime._load_program_from_buffer(b"\xff" * 128) + except RuntimeError: + pass + return values.__dlpack__(stream=stream) + + def __dlpack_device__(self): + return values.__dlpack_device__() + + captured = StringIO() + saved_stderr = sys.stderr + sys.stderr = captured + try: + module(LogsDuringTheCall()) + finally: + sys.stderr = saved_stderr + + text = captured.getvalue() + lines = text.splitlines() + self.assertRegex( + lines[0], + r"^\[\d+ earlier bytes of the runtime log were left out\]$", + ) + kept = len(text) - len(lines[0]) - 1 + self.assertGreater(kept, 512 * 1024 - 1024) + self.assertLessEqual(kept, 1024 * 1024) + self.assertIn("Program identifier '\\xff\\xff\\xff\\xff'", lines[-1]) + + def test_error_message_cut_in_a_character_raises_runtime_error(self): + # An error message is cut at a fixed length. With these paths, the cut + # falls inside a character for at least one of them. + for prefix in ("", "a", "ab"): + with self.assertRaises(RuntimeError): + self.runtime._load_for_executorch(prefix + "\u4e2d" * 60 + ".pte") + def test_bfloat16_output_preserves_dtype(self): class ModuleAddBFloat16(ModuleAdd): def get_inputs(self): From d55e0793765b7ab6ab62df2422270766700d31a6 Mon Sep 17 00:00:00 2001 From: Jacob Stevens Date: Tue, 3 Nov 2026 16:00:42 -0500 Subject: [PATCH 021/128] Support Whisper positional indexing (#23903) (#23903) Summary: Lower matrix row gathers from `aten.index.Tensor` through the existing embedding path and lower positive bounded `aten.repeat` shapes with `ggml_repeat`. Preserve snapshots when repeat aliases its input so later cache writes cannot change previously computed values. Allow row-index validation to follow repeats and validate integer `aten.arange.start_step` bounds when building the graph. This supports Whisper's encoder position embeddings and decoder cache-position gathers while retaining input bounds checks on cached graph replay. Review row-index validation and gather lowering first, then repeat lowering and the operator regressions. Authored with assistance from Codex. Differential Revision: D125479769 --- .../runtime/engine/ggml/GgmlGraphBuilder.cpp | 57 ++++++++++--------- .../runtime/engine/ggml/GgmlGraphBuilder.h | 3 +- .../runtime/engine/ggml/GgmlLowerLinear.cpp | 26 +++++++-- .../runtime/engine/ggml/GgmlLowerShape.cpp | 27 +++++++++ .../native/test/runtime/ggml/test_ggml_ops.py | 50 ++++++++++++++++ 5 files changed, 132 insertions(+), 31 deletions(-) diff --git a/backends/native/runtime/engine/ggml/GgmlGraphBuilder.cpp b/backends/native/runtime/engine/ggml/GgmlGraphBuilder.cpp index 372176f8d2d..ee6718ebacc 100644 --- a/backends/native/runtime/engine/ggml/GgmlGraphBuilder.cpp +++ b/backends/native/runtime/engine/ggml/GgmlGraphBuilder.cpp @@ -649,8 +649,8 @@ void GraphBuilder::lower(const Node& node) { const std::string key = op_key(node.target); if (key == "aten.linear.default") { lower_linear(node); - } else if (key == "aten.embedding.default") { - lower_embedding(node); + } else if (key == "aten.embedding.default" || key == "aten.index.Tensor") { + lower_row_gather(node); } else if (key == "aten.rms_norm.default") { lower_rms_norm(node); } else if ( @@ -713,6 +713,8 @@ void GraphBuilder::lower(const Node& node) { } else if ( key == "aten.expand_copy.default" || key == "aten.expand.default") { lower_expand(node); + } else if (key == "aten.repeat.default") { + lower_repeat(node); } else if (key == "aten.cat.default") { lower_cat(node); } else if ( @@ -753,31 +755,21 @@ void GraphBuilder::lower(const Node& node) { } // Row indices are checked on the host, which needs them to come from a -// graph input, possibly through integer casts and views; any other source -// is rejected. +// graph input through integer casts, views or repeats, or an integer arange +// whose bounds are resolved when building the graph. void GraphBuilder::check_row_indices(ValueId id, int64_t rows) { static const std::unordered_set kSameValues = { - "aten._to_copy.default", - "aten.alias.default", - "aten.clone.default", - "aten.contiguous.default", - "aten.expand.default", - "aten.expand_copy.default", - "aten.permute.default", - "aten.permute_copy.default", - "aten.reshape.default", - "aten._unsafe_view.default", - "aten.select.int", - "aten.select_copy.int", - "aten.slice.Tensor", - "aten.slice_copy.Tensor", - "aten.squeeze.dims", - "aten.squeeze_copy.dims", - "aten.transpose_copy.int", - "aten.unsqueeze.default", - "aten.unsqueeze_copy.default", - "aten.view.default", - "aten.view_copy.default", + "aten._to_copy.default", "aten.alias.default", + "aten.clone.default", "aten.contiguous.default", + "aten.expand.default", "aten.expand_copy.default", + "aten.permute.default", "aten.permute_copy.default", + "aten.repeat.default", "aten.reshape.default", + "aten._unsafe_view.default", "aten.select.int", + "aten.select_copy.int", "aten.slice.Tensor", + "aten.slice_copy.Tensor", "aten.squeeze.dims", + "aten.squeeze_copy.dims", "aten.transpose_copy.int", + "aten.unsqueeze.default", "aten.unsqueeze_copy.default", + "aten.view.default", "aten.view_copy.default", }; const auto is_index = [this](ValueId value) { const ScalarType dtype = method_.graph.value(value).tensor_meta().dtype; @@ -796,6 +788,19 @@ void GraphBuilder::check_row_indices(ValueId id, int64_t rows) { break; } const Node& node = method_.graph.node(producer); + if (node.is_call() && op_key(node.target) == "aten.arange.start_step") { + const int64_t start = int_arg(node, "start", 0, 0); + const int64_t step = int_arg(node, "step", 2, 1, true); + const int64_t count = ggml_nelements(tensor(id).tensor); + int64_t last = 0; + if (count > 0 && + (__builtin_mul_overflow(count - 1, step, &last) || + __builtin_add_overflow(start, last, &last) || + std::min(start, last) < 0 || std::max(start, last) >= rows)) { + throw std::out_of_range("ggml: arange row index is outside the table"); + } + return; + } const NamedArgument* source = find_arg(node, "self", 0); if (!node.is_call() || !kSameValues.contains(op_key(node.target)) || source == nullptr || source->arg.kind() != ArgKind::Tensor) { @@ -805,7 +810,7 @@ void GraphBuilder::check_row_indices(ValueId id, int64_t rows) { } throw std::runtime_error( "ggml: row indices must come from a graph input, through integer " - "casts and views only"); + "casts, views or repeats, or an integer arange"); } ggml_tensor* GraphBuilder::as_f32(ggml_tensor* tensor) { diff --git a/backends/native/runtime/engine/ggml/GgmlGraphBuilder.h b/backends/native/runtime/engine/ggml/GgmlGraphBuilder.h index ee8db65dfc7..2f69e368fe0 100644 --- a/backends/native/runtime/engine/ggml/GgmlGraphBuilder.h +++ b/backends/native/runtime/engine/ggml/GgmlGraphBuilder.h @@ -186,7 +186,7 @@ class GraphBuilder { void set_single_output(const Node& node, TensorRef result); void lower(const Node& node); void lower_linear(const Node& node); - void lower_embedding(const Node& node); + void lower_row_gather(const Node& node); void check_row_indices(ValueId id, int64_t rows); void lower_rms_norm(const Node& node); void lower_layer_norm(const Node& node); @@ -244,6 +244,7 @@ class GraphBuilder { void lower_unsqueeze(const Node& node); void lower_squeeze(const Node& node); void lower_expand(const Node& node); + void lower_repeat(const Node& node); void lower_cat(const Node& node); void lower_matmul(const Node& node); void lower_update_cache(const Node& node); diff --git a/backends/native/runtime/engine/ggml/GgmlLowerLinear.cpp b/backends/native/runtime/engine/ggml/GgmlLowerLinear.cpp index 93281445b80..01625b42a09 100644 --- a/backends/native/runtime/engine/ggml/GgmlLowerLinear.cpp +++ b/backends/native/runtime/engine/ggml/GgmlLowerLinear.cpp @@ -47,11 +47,29 @@ void GraphBuilder::lower_linear(const Node& node) { set_single_output(node, TensorRef{result, std::move(sizes)}); } -void GraphBuilder::lower_embedding(const Node& node) { +void GraphBuilder::lower_row_gather(const Node& node) { const TensorRef weight = tensor_arg(node, "weight", 0); - TensorRef indices = tensor_arg(node, "indices", 1); - check_row_indices( - require_arg(node, "indices", 1).arg.as_tensor().id, weight.sizes.at(0)); + const Argument& arg = require_arg(node, "indices", 1).arg; + ValueId indices_id = kInvalid; + if (op_key(node.target) == "aten.embedding.default") { + indices_id = arg.as_tensor().id; + } else { + const std::vector ids = arg.kind() == ArgKind::TensorList + ? arg.as_tensor_list().ids + : arg.as_optional_tensor_list().ids; + if (ids.size() != 1 || !valid(ids.front())) { + throw std::runtime_error("ggml: index supports one row-index tensor"); + } + indices_id = ids.front(); + } + const ScalarType dtype = method_.graph.value(indices_id).tensor_meta().dtype; + if (weight.sizes.size() != 2 || + (dtype != ScalarType::Int && dtype != ScalarType::Long)) { + throw std::runtime_error( + "ggml: row gather needs a matrix and integer indices"); + } + TensorRef indices = tensor(indices_id); + check_row_indices(indices_id, weight.sizes[0]); if (indices.tensor->type != GGML_TYPE_I32) { indices.tensor = ggml_cast(instance_.context.get(), indices.tensor, GGML_TYPE_I32); diff --git a/backends/native/runtime/engine/ggml/GgmlLowerShape.cpp b/backends/native/runtime/engine/ggml/GgmlLowerShape.cpp index 2927f5b5dba..96382fd6e94 100644 --- a/backends/native/runtime/engine/ggml/GgmlLowerShape.cpp +++ b/backends/native/runtime/engine/ggml/GgmlLowerShape.cpp @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -294,6 +295,32 @@ void GraphBuilder::lower_expand(const Node& node) { set_single_output(node, TensorRef{result, std::move(sizes)}); } +void GraphBuilder::lower_repeat(const Node& node) { + const TensorRef input = tensor_arg(node, "self", 0); + if (input.tensor == nullptr) { + throw std::runtime_error("ggml: repeat input has no tensor"); + } + std::vector sizes = int_list(require_arg(node, "repeats", 1).arg); + if (sizes.size() < input.sizes.size() || sizes.size() > 4) { + throw std::runtime_error( + "ggml: repeat needs input rank <= repeats rank <= 4"); + } + const size_t offset = sizes.size() - input.sizes.size(); + for (size_t i = 0; i < sizes.size(); ++i) { + const int64_t extent = i < offset ? 1 : input.sizes[i - offset]; + if (extent <= 0 || sizes[i] <= 0 || + sizes[i] > std::numeric_limits::max() / extent) { + throw std::runtime_error("ggml: repeat requires positive, bounded sizes"); + } + sizes[i] *= extent; + } + ggml_context* ctx = instance_.context.get(); + ggml_tensor* shape = new_tensor(ctx, input.tensor->type, sizes); + ggml_tensor* result = ggml_repeat(ctx, input.tensor, shape); + // A no-op repeat must still snapshot storage that can be written later. + set_single_output(node, TensorRef{snapshot(result), std::move(sizes)}); +} + void GraphBuilder::lower_cat(const Node& node) { const Argument& tensors_arg = require_arg(node, "tensors", 0).arg; if (tensors_arg.kind() != ArgKind::TensorList) { diff --git a/backends/native/test/runtime/ggml/test_ggml_ops.py b/backends/native/test/runtime/ggml/test_ggml_ops.py index 54d31653a77..2a7169ffecd 100644 --- a/backends/native/test/runtime/ggml/test_ggml_ops.py +++ b/backends/native/test/runtime/ggml/test_ggml_ops.py @@ -884,6 +884,56 @@ def forward(self, indices): self._check(SqueezedEmbedding(), (torch.tensor([[1, 3, 9]]),)) + def test_embedding_arange_indices(self): + class PositionEmbedding(Embedding): + def forward(self, x): + return self.embedding(torch.arange(1, 10, 2)) + x + + self._check(PositionEmbedding(), (torch.randn(5, 8),)) + + def test_embedding_arange_indices_rejects_out_of_bounds(self): + class InvalidPositionEmbedding(Embedding): + def forward(self, x): + return self.embedding(torch.arange(-1, 4)) + x + + with self.assertRaisesRegex(AssertionError, "arange row index"): + # Export does not execute the invalid gather; the runner must reject it. + with tempfile.TemporaryDirectory() as tmp: + outdir = Path(tmp) + x = torch.randn(5, 8) + package = outdir / "model.ptn" + ep = torch.export.export(InvalidPositionEmbedding().eval(), (x,)) + to_native(ep).save(str(package)) + _run_ggml(package, [(x,)], outdir, 1) + + def test_repeated_row_gather_rechecks_indices(self): + class RepeatedRowGather(Embedding): + def forward(self, indices): + return self.embedding.weight[indices.unsqueeze(0).repeat(2, 1)] + + model = RepeatedRowGather().eval() + inputs = (torch.tensor([1, 3, 9]),) + self._check(model, inputs) + with tempfile.TemporaryDirectory() as tmp: + outdir = Path(tmp) + package = outdir / "model.ptn" + to_native(torch.export.export(model, inputs)).save(str(package)) + for invalid in (-1, 10): + with self.subTest(invalid=invalid): + with self.assertRaisesRegex(AssertionError, "row index is outside"): + _run_ggml( + package, + [inputs, (torch.tensor([1, invalid, 3]),)], + outdir, + 1, + ) + + def test_repeat_tiles_non_singleton_dimensions(self): + self._check( + Lambda(lambda x: x.repeat(2, 3, 2)), + (torch.arange(6, dtype=torch.float32).reshape(2, 3),), + ) + def test_rms_norm(self): self._check(RMSNorm(), (torch.randn(2, 3, 16),)) From c68298d496c94857ace321aead06dae3bb2593e3 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Tue, 3 Nov 2026 13:05:22 -0800 Subject: [PATCH 022/128] CUDA backend: lower rotary embeddings written with complex numbers (#24403) ### What is wrong today Rotary position embeddings are often written with complex numbers: `torch.polar`, `torch.view_as_complex`, a product, then `torch.view_as_real`. Such a model exports, but the CUDA backend cannot lower it: ``` RuntimeError: Method forward missing fallback kernels (5 total): - aoti_torch_cuda_mul_Tensor - aoti_torch_cuda_polar - aten::view_as_complex_copy - aten::view_as_real_copy - prims::convert_element_type ``` Inductor generates no code for complex numbers, so it calls ATen kernels, which this backend does not ship. Torch-TensorRT leaves these ops to the CUDA backend, so its programs stop at the same error. ### What this change does A new pass, `ReplaceComplexWithRealPass`, stores each complex tensor as a real tensor with a last dim of size 2 (the layout of `view_as_real`) and rewrites the ops on it as real math: - `polar` becomes cosine and sine times the magnitude; a complex product becomes four real products. - `view_as_complex` and `view_as_real` go away. - Views, slices, splits, indexing, repeats, joins and casts keep their op, with the dims and dtype of the pair. This includes `_reshape_copy`, which Torch-TensorRT makes of every view, such as Llama's `freqs_cis.view(...)`. - Complex constants, buffers and parameters are stored in the real layout. If the pass cannot rewrite every op on a complex tensor, for example a complex add, a complex input or output of the partition, a `torch.cond` branch, or complex32, it leaves the program as it is and logs why. So the complex math has to stay inside one CUDA partition. Complex128 becomes float64, which the CUDA runtime supports. Also in the CUDA backend: - With CUDA graphs on, a copy or clone of a CUDA tensor during the capture aborted the process. AOTI returns a part of a constant through such a clone. These shims now refuse inside a capture, as `.item()` does, and the call runs again without a graph. - On ROCm, `test_varlen_attn` compared no case. It now skips only windows over query and key sequences that differ, which AOTriton lines up differently. ### What was tested With torch 2.14 on an A100: - `test_complex_rotary_embedding` (GPU) and `test_replace_complex_with_real.py` (CPU) compare rewritten programs with eager, in float32, bfloat16 and complex128, with a dynamic length, `_reshape_copy` and a buffer with two placeholders. Unsupported programs stay as they are. - `test_constant_copy_runs_without_a_graph` and `test_aoti_torch_copy_` cover the copy and clone refusal. - A Llama-style rotary embedding lowers through Torch-TensorRT 2.14 and the CUDA backend. The new tests fail without their fixes, and the other CUDA tests pass. The ROCm change was not run on ROCm. --- backends/aoti/aoti_backend.py | 6 +- backends/cuda/BUCK | 1 + backends/cuda/cuda_backend.py | 4 + .../cuda/passes/replace_complex_with_real.py | 398 ++++++++++++++++++ .../tests/test_replace_complex_with_real.py | 159 +++++++ backends/cuda/runtime/shims/memory.cpp | 50 ++- backends/cuda/runtime/shims/memory.h | 11 +- .../shims/tests/test_aoti_torch_copy_.cpp | 78 ++++ backends/cuda/tests/test_cuda_export.py | 95 ++++- .../tests/test_output_stride_rearrange.py | 25 ++ 10 files changed, 796 insertions(+), 31 deletions(-) create mode 100644 backends/cuda/passes/replace_complex_with_real.py create mode 100644 backends/cuda/passes/tests/test_replace_complex_with_real.py diff --git a/backends/aoti/aoti_backend.py b/backends/aoti/aoti_backend.py index a069db4c1fc..785d89d9c9f 100644 --- a/backends/aoti/aoti_backend.py +++ b/backends/aoti/aoti_backend.py @@ -331,9 +331,9 @@ def preprocess( custom_pass(device_edge_program.graph_module) # ``run_decompositions`` retraces the complete ExportedProgram even - # when none of the table's operators occur in the graph. The in-place - # passes above preserve graph inputs and outputs, so the existing graph - # signature remains valid when the decomposition table cannot apply. + # when none of the table's operators occur in the graph. Nothing else + # rebuilds the graph signature, so the in-place passes above must keep + # it valid when the decomposition table cannot apply. if contains_any_call_fn_target_op( device_edge_program.graph_module, decomposition_table ): diff --git a/backends/cuda/BUCK b/backends/cuda/BUCK index 7e8d4c88675..0b924242754 100644 --- a/backends/cuda/BUCK +++ b/backends/cuda/BUCK @@ -80,6 +80,7 @@ fbcode_target( "passes/move_cond_predicate_to_cpu.py", "passes/lower_offgraph_kv.py", "passes/remove_assert_async.py", + "passes/replace_complex_with_real.py", "passes/replace_fused_sdpa.py", "passes/replace_int64_floordiv.py", "passes/replace_masked_copy_with_where.py", diff --git a/backends/cuda/cuda_backend.py b/backends/cuda/cuda_backend.py index 3f368cdd37e..b35a6373dd4 100644 --- a/backends/cuda/cuda_backend.py +++ b/backends/cuda/cuda_backend.py @@ -44,6 +44,9 @@ MoveCondPredicateToCpuPass, ) from executorch.backends.cuda.passes.remove_assert_async import RemoveAssertAsyncPass +from executorch.backends.cuda.passes.replace_complex_with_real import ( + ReplaceComplexWithRealPass, +) from executorch.backends.cuda.passes.replace_fused_sdpa import ( ReplaceFusedSdpaWithSdpaPass, ) @@ -1266,6 +1269,7 @@ def get_custom_passes(cls, compile_specs: List[CompileSpec]) -> List[typing.Any] triton_kernel_mode = mode passes = [ RemoveAssertAsyncPass(), + ReplaceComplexWithRealPass(), MoveCondPredicateToCpuPass(), ReplaceInt64FloorDivWithFloatPass(), ReplaceFusedSdpaWithSdpaPass(), diff --git a/backends/cuda/passes/replace_complex_with_real.py b/backends/cuda/passes/replace_complex_with_real.py new file mode 100644 index 00000000000..baeb1183474 --- /dev/null +++ b/backends/cuda/passes/replace_complex_with_real.py @@ -0,0 +1,398 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +Graph Transformation Pass for Complex Numbers. + +Inductor generates no code for complex numbers. It calls an ATen kernel for +every op that reads or writes a complex tensor, and this backend ships none of +those kernels. So this pass keeps each complex tensor as a real tensor with a +last dim of size 2 that holds the real and imaginary parts, the layout of +``torch.view_as_real``, and rewrites the ops on it as ops on real numbers: + + view_as_complex(x) -> x + view_as_real(z) -> z + polar(r, theta) -> stack([r * cos(theta), r * sin(theta)], -1) + z * w -> stack([zr * wr - zi * wi, zr * wi + zi * wr], -1) + +Views, copies, indexing, repeats, splits, concatenation and stacking act on +both parts alike, so they keep their op, with the dims they name counted in +the complex tensor. A cast of a real tensor to complex gets zero imaginary +parts. A complex constant, parameter or buffer is stored in the real layout +too. A decomposition table cannot do this, since a decomposition returns the +dtype of the op it replaces. + +The pass rewrites a program only when it can rewrite every op that reads or +writes a complex tensor in it. Otherwise it leaves the program as it is and +logs why, and the backend reports the ops it has no kernels for. It never +rewrites control flow branches, such as those of torch.cond, or complex32 +values. +""" + +import logging +import operator +from typing import Any, Callable, Dict, List, Optional, Set, Tuple + +import torch +import torch.utils._pytree as pytree +from executorch.exir.dialects._ops import ops as exir_ops +from torch.export import ExportedProgram +from torch.fx import Graph, Node + +logger = logging.getLogger(__name__) + +_aten = exir_ops.edge.aten + + +def _complex_vals(node: Node) -> List[torch.Tensor]: + val = node.meta.get("val") + vals = val if isinstance(val, (list, tuple)) else (val,) + return [v for v in vals if isinstance(v, torch.Tensor) and v.is_complex()] + + +# The pair's extra dim comes last, so a dim of the complex tensor names the +# same dim of the pair once it is counted from the front. +def _dim(dim: int, ndim: int) -> int: + return dim + ndim if dim < 0 else dim + + +def _dims(dims: List[int], ndim: int) -> List[int]: + return [_dim(d, ndim) for d in dims] + + +def _new_dim(dim: int, ndim: int) -> int: + return _dim(dim, ndim + 1) + + +def _permutation(dims: List[int], ndim: int) -> List[int]: + return _dims(dims, ndim) + [ndim] + + +def _repeats(repeats: List[Any], ndim: int) -> List[Any]: + return [*repeats, 1] + + +def _size(size: List[Any], ndim: int) -> List[Any]: + return [*size, 2] + + +# Ops that act on the real and imaginary parts alike. For each argument that +# names dims or sizes of the complex tensor, the entry gives the argument's +# position, and how to name the same dims of the pair. These arguments' +# default values never need changing. +_SAME_ON_BOTH_PARTS: Dict[Any, Tuple[Tuple[int, Callable[..., Any]], ...]] = { + # Torch-TensorRT turns every view into _reshape_copy. + _aten._reshape_copy.default: ((1, _size),), + _aten.alias_copy.default: (), + _aten.cat.default: ((1, _dim),), + _aten.clone.default: (), + _aten.expand_copy.default: ((1, _size),), + _aten.index.Tensor: (), + _aten.permute_copy.default: ((1, _permutation),), + _aten.repeat.default: ((1, _repeats),), + _aten.select.int: ((1, _dim),), + _aten.select_copy.int: ((1, _dim),), + _aten.slice.Tensor: ((1, _dim),), + _aten.slice_copy.Tensor: ((1, _dim),), + _aten.split.Tensor: ((2, _dim),), + _aten.split_copy.Tensor: ((2, _dim),), + _aten.split_with_sizes_copy.default: ((2, _dim),), + _aten.squeeze_copy.default: (), + _aten.squeeze_copy.dim: ((1, _dim),), + _aten.squeeze_copy.dims: ((1, _dims),), + _aten.stack.default: ((1, _new_dim),), + _aten.transpose_copy.int: ((1, _dim), (2, _dim)), + _aten.unbind_copy.int: ((1, _dim),), + _aten.unsqueeze_copy.default: ((1, _new_dim),), + _aten.view_copy.default: ((1, _size),), +} + +_LIST_INPUTS = (_aten.cat.default, _aten.stack.default) + +# Copies that may change the dtype. The pair gets the precision of the +# complex dtype, and the complex tensor's dim order with its extra dim last. +_COPIES = ( + _aten._to_copy.default, + exir_ops.edge.dim_order_ops._clone_dim_order.default, + exir_ops.edge.dim_order_ops._to_dim_order_copy.default, +) +_DENSE_FORMATS = (None, torch.preserve_format, torch.contiguous_format) +_PROVENANCE = ("from_node", "nn_module_stack", "source_fn_stack", "stack_trace") + +# The fake kernels of some view copies, such as permute_copy, make dynamic +# sizes static or fail on them, so a new view copy gets its value from its +# view, laid out as the copy's would be. view_copy keeps its own kernel, which +# keeps dynamic sizes, because view fails on some strides that view_copy +# accepts. +_VIEWS: Dict[Any, Any] = { + _aten.alias_copy.default: torch.ops.aten.alias.default, + _aten.expand_copy.default: torch.ops.aten.expand.default, + _aten.permute_copy.default: torch.ops.aten.permute.default, + _aten.select_copy.int: torch.ops.aten.select.int, + _aten.slice_copy.Tensor: torch.ops.aten.slice.Tensor, + _aten.split_copy.Tensor: torch.ops.aten.split.Tensor, + _aten.split_with_sizes_copy.default: torch.ops.aten.split_with_sizes.default, + _aten.squeeze_copy.default: torch.ops.aten.squeeze.default, + _aten.squeeze_copy.dim: torch.ops.aten.squeeze.dim, + _aten.squeeze_copy.dims: torch.ops.aten.squeeze.dims, + _aten.transpose_copy.int: torch.ops.aten.transpose.int, + _aten.unbind_copy.int: torch.ops.aten.unbind.int, + _aten.unsqueeze_copy.default: torch.ops.aten.unsqueeze.default, +} + + +class _Rewriter: + """Builds, in graph order, the real replacement of every node that reads + or writes a complex tensor.""" + + def __init__(self, graph: Graph, complex_nodes: Set[Node]) -> None: + self.graph = graph + self.complex_nodes = complex_nodes + self.ndims = { + node: node.meta["val"].dim() + for node in complex_nodes + if isinstance(node.meta["val"], torch.Tensor) + } + self.replacements: Dict[Node, Node] = {} + self.source: Optional[Node] = None + + def get(self, value: Any) -> Any: + """``value`` with each complex node in it replaced by its pair.""" + return torch.fx.map_arg( + value, + lambda n: self.replacements[n] if n in self.complex_nodes else n, + ) + + def call(self, target: Any, *args: Any, **kwargs: Any) -> Node: + node = self.graph.call_function(target, args, kwargs) + # Errors and profiles then point at the op that this one replaces. + node.meta.update( + {k: v for k, v in self.source.meta.items() if k in _PROVENANCE} + ) + fake_args, fake_kwargs = torch.fx.map_arg( + (args, kwargs), lambda n: n.meta["val"] + ) + value = _VIEWS.get(target, target)(*fake_args, **fake_kwargs) + if target in _VIEWS: + value = pytree.tree_map_only( + torch.Tensor, + lambda t: t.clone(memory_format=torch.contiguous_format), + value, + ) + node.meta["val"] = value + return node + + def parts(self, value: Node) -> List[Node]: + pair = self.get(value) + return [self.call(_aten.select.int, pair, -1, i) for i in (0, 1)] + + +def _view_as_complex(rw: _Rewriter, node: Node) -> Node: + return node.args[0] + + +def _view_as_real(rw: _Rewriter, node: Node) -> Node: + return rw.get(node.args[0]) + + +def _polar(rw: _Rewriter, node: Node) -> Node: + magnitude, angle = node.args + parts = [ + rw.call(_aten.mul.Tensor, magnitude, rw.call(trig, angle)) + for trig in (_aten.cos.default, _aten.sin.default) + ] + return rw.call(_aten.stack.default, parts, -1) + + +def _mul(rw: _Rewriter, node: Node) -> Node: + (ar, ai), (br, bi) = (rw.parts(arg) for arg in node.args) + mul = _aten.mul.Tensor + real = rw.call(_aten.sub.Tensor, rw.call(mul, ar, br), rw.call(mul, ai, bi)) + imag = rw.call(_aten.add.Tensor, rw.call(mul, ar, bi), rw.call(mul, ai, br)) + return rw.call(_aten.stack.default, [real, imag], -1) + + +def _copy(rw: _Rewriter, node: Node) -> Node: + source, kwargs = node.args[0], dict(node.kwargs) + if kwargs.get("dtype") is not None: + kwargs["dtype"] = kwargs["dtype"].to_real() + if source not in rw.complex_nodes: + # Edge programs cast a real operand to complex before complex math + # with it. The cast gives zero imaginary parts. + real = rw.call(node.target, source, **kwargs) + imag = rw.call(_aten.zeros_like.default, real) + return rw.call(_aten.stack.default, [real, imag], -1) + if kwargs.get("dim_order") is not None: + kwargs["dim_order"] = [*kwargs["dim_order"], len(kwargs["dim_order"])] + return rw.call(node.target, rw.get(source), **kwargs) + + +def _same_on_both_parts(rw: _Rewriter, node: Node) -> Node: + args = list(node.args) + tensor = args[0][0] if node.target in _LIST_INPUTS else args[0] + for index, pair_value in _SAME_ON_BOTH_PARTS[node.target]: + if index < len(args): + args[index] = pair_value(args[index], rw.ndims[tensor]) + return rw.call(node.target, *rw.get(args), **rw.get(dict(node.kwargs))) + + +def _getitem(rw: _Rewriter, node: Node) -> Node: + return rw.call(operator.getitem, rw.get(node.args[0]), node.args[1]) + + +_REWRITES: Dict[Any, Callable[[_Rewriter, Node], Node]] = { + _aten.view_as_complex_copy.default: _view_as_complex, + _aten.view_as_real_copy.default: _view_as_real, + _aten.polar.default: _polar, + _aten.mul.Tensor: _mul, + operator.getitem: _getitem, + **{op: _copy for op in _COPIES}, + **{op: _same_on_both_parts for op in _SAME_ON_BOTH_PARTS}, +} + + +def _keeps_dims_in_order(node: Node) -> bool: + # With the pair's dim after them, the dims of a channels-last layout are + # in an order that no memory format describes, and the dim order ops + # accept only those that one does. + dim_order = node.kwargs.get("dim_order") + return node.kwargs.get("memory_format") in _DENSE_FORMATS and ( + dim_order is None or list(dim_order) == list(range(len(dim_order))) + ) + + +def _can_rewrite(node: Node, complex_nodes: Set[Node]) -> bool: + if node.op != "call_function" or node.target not in _REWRITES: + return False + # The rewrites read by position the arguments that exported programs + # pass by position. + schema = getattr(node.target, "_schema", None) + if schema is not None and any( + not arg.kwarg_only for arg in schema.arguments if arg.name in node.kwargs + ): + return False + if node.target in _COPIES: + # A cast from complex to real has no rewrite here. + return node in complex_nodes and _keeps_dims_in_order(node) + if node.target is _aten.clone.default: + return _keeps_dims_in_order(node) + if node.target is _aten.mul.Tensor: + # Edge programs cast a real factor to complex first. + return all(arg in complex_nodes for arg in node.args) + if node.target in _LIST_INPUTS: + return all(tensor in complex_nodes for tensor in node.args[0]) + return True + + +def _constant_name(exported_program: ExportedProgram, node: Node) -> Optional[str]: + """The name of the constant, parameter or buffer that ``node`` reads, + unless it is a buffer that the graph writes.""" + signature = exported_program.graph_signature + for inputs in ( + signature.inputs_to_lifted_tensor_constants, + signature.inputs_to_parameters, + ): + if node.name in inputs: + return inputs[node.name] + buffer = signature.inputs_to_buffers.get(node.name) + if buffer in signature.buffers_to_mutate.values(): + return None + return buffer + + +def _store_as_real(exported_program: ExportedProgram, node: Node) -> None: + name = _constant_name(exported_program, node) + tensors = ( + exported_program._constants + if name in exported_program.constants + else exported_program._state_dict + ) + tensor = tensors[name] + # Partitioning can give one tensor several placeholders, and the first of + # them stores it. + if tensor.is_complex(): + pair = torch.view_as_real(tensor.detach().resolve_conj()).clone() + if isinstance(tensor, torch.nn.Parameter): + pair = torch.nn.Parameter(pair, requires_grad=False) + tensors[name] = pair + node.meta["val"] = torch.view_as_real(node.meta["val"].resolve_conj()) + + +def _reason_to_keep( + exported_program: ExportedProgram, complex_nodes: Set[Node], affected: Set[Node] +) -> Optional[str]: + """Why the pass must leave the program as it is, if it must.""" + graph_module = exported_program.graph_module + for module in graph_module.modules(): + if module is not graph_module and isinstance(module, torch.fx.GraphModule): + for node in module.graph.nodes: + if _complex_vals(node): + return f"it does not rewrite control flow branches: {node.format_node()}" + for node in graph_module.graph.nodes: + if node not in affected: + continue + if node.op == "placeholder": + supported = _constant_name(exported_program, node) is not None + else: + supported = _can_rewrite(node, complex_nodes) + if not supported: + return f"it cannot rewrite {node.format_node()}" + # PyTorch computes complex32 math in float32, and its pair would + # compute in float16. + if any(val.dtype == torch.complex32 for val in _complex_vals(node)): + return f"it does not rewrite complex32: {node.format_node()}" + return None + + +class ReplaceComplexWithRealPass: + """ + Replace complex tensors with real tensors that hold their real and + imaginary parts in a last dim of size 2, and the ops on them with ops on + real numbers, so that Inductor can generate code for them. See the module + docstring for the ops it handles. + """ + + requires_exported_program = True + + def __call__(self, exported_program: ExportedProgram) -> None: + graph_module = exported_program.graph_module + graph = graph_module.graph + complex_nodes = {node for node in graph.nodes if _complex_vals(node)} + affected = complex_nodes | { + user for node in complex_nodes for user in node.users + } + reason = _reason_to_keep(exported_program, complex_nodes, affected) + if reason is not None: + logger.warning( + "The CUDA backend cannot lower the complex numbers in this " + "program, since %s", + reason, + ) + return + if not complex_nodes: + return + + rw = _Rewriter(graph, complex_nodes) + order = [node for node in graph.nodes if node in affected] + for node in order: + if node.op == "placeholder": + _store_as_real(exported_program, node) + rw.replacements[node] = node + continue + rw.source = node + with graph.inserting_before(node): + rw.replacements[node] = _REWRITES[node.target](rw, node) + signature = exported_program.graph_signature + for node in reversed(order): + replacement = rw.replacements[node] + if replacement is not node: + node.replace_all_uses_with(replacement) + # The signature names the program's outputs by node, and a + # view_as_real can be one of them. + signature.replace_all_uses(node.name, replacement.name) + graph.erase_node(node) + graph.lint() + graph_module.recompile() diff --git a/backends/cuda/passes/tests/test_replace_complex_with_real.py b/backends/cuda/passes/tests/test_replace_complex_with_real.py new file mode 100644 index 00000000000..793fe16555c --- /dev/null +++ b/backends/cuda/passes/tests/test_replace_complex_with_real.py @@ -0,0 +1,159 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import unittest + +import torch +import torch.utils._pytree as pytree +from executorch.backends.cuda.cuda_backend import CudaBackend +from executorch.backends.cuda.passes.replace_complex_with_real import ( + _VIEWS, + ReplaceComplexWithRealPass, +) +from executorch.backends.cuda.passes.tests.test_replace_fused_sdpa import ( + _Fn, + _program_for_cuda_passes, +) +from executorch.exir.backend.canonical_partitioners.duplicate_constant_node_pass import ( + duplicate_constant_node, +) + + +class TestReplaceComplexWithRealPass(unittest.TestCase): + def test_registered(self): + """The rewrite must stay in the CUDA backend's passes.""" + self.assertTrue( + any( + isinstance(p, ReplaceComplexWithRealPass) + for p in CudaBackend.get_custom_passes([]) + ) + ) + + def test_rewrite(self): + """The rewrite of complex numbers gives eager's results.""" + angles = torch.outer(torch.arange(8.0), torch.rand(3)) + + class Rotate(torch.nn.Module): + def __init__(self): + super().__init__() + # A plain attribute becomes a constant of the program. + self.freqs = torch.polar(torch.ones_like(angles), angles) + self.phase = torch.nn.Parameter( + torch.polar(torch.ones(2, 6), torch.rand(2, 6)), requires_grad=False + ) + + def forward(self, x, positions, scale): + f = torch.stack(self.freqs[positions].unbind(-1), -1) + f = f.unsqueeze(-2).expand(-1, 2, -1).flatten(-2) + # Torch-TensorRT turns views into _reshape_copy. + f = torch.ops.aten._reshape_copy(f, [-1, 1, 6]) + pairs = torch.view_as_complex(x.unflatten(-1, (-1, 2))) + # The product keeps the permuted layout, so export flattens + # the transpose back with a view. + scaled = pairs.unflatten(-1, (2, 3)).permute(0, 1, -1, -2) * scale + scaled = scaled.transpose(-1, -2).flatten(-2) + phase = self.phase.repeat(2, 1).select(-2, 1).detach() + # The cast sets the precision of everything after it. + rotated = scaled * f.to(torch.complex128) * phase + first, second = rotated.chunk(2, dim=-1) + second = torch.cat(second.split([1, 2], dim=-1), -1) + joined = torch.cat([first, second], -1) + return ( + torch.view_as_real(joined.squeeze(-2).clone()), + torch.view_as_real(joined.unsqueeze(0).squeeze((0, -2))), + ) + + model = Rotate() + scale = torch.randn(3, 2) + x = torch.randn(5, 1, 12) + length = torch.export.Dim("length", min=2, max=64) + program = _program_for_cuda_passes( + model, + (x, torch.tensor([3, 0, 7, 7, 1]), scale), + dynamic_shapes=({0: length}, {0: length}, None), + ) + ReplaceComplexWithRealPass()(program) + for node in program.graph.nodes: + val = node.meta.get("val") + self.assertFalse(isinstance(val, torch.Tensor) and val.is_complex()) + if node.target in _VIEWS: + # A view copy returns new tensors laid out in order. + self.assertTrue(all(t.is_contiguous() for t in pytree.tree_leaves(val))) + # The backend retraces the program when its decomposition table applies. + program = program.run_decompositions({}) + for positions in (torch.tensor([3, 0, 7, 7, 1]), torch.tensor([6, 2, 4])): + inputs = (x[: len(positions)], positions, scale) + torch.testing.assert_close(program.module()(*inputs), model(*inputs)) + + def test_tensor_with_two_placeholders(self): + """Partitioning gives a tensor a placeholder per op that reads it when + another backend takes ops between those, and the pass stores the + tensor once.""" + + class Rotate(torch.nn.Module): + def __init__(self): + super().__init__() + self.register_buffer("freqs", torch.polar(torch.ones(3), torch.rand(3))) + + def forward(self, x, y): + return tuple( + torch.view_as_real(torch.view_as_complex(t) * self.freqs) + for t in (x, y) + ) + + model = Rotate() + inputs = (torch.randn(3, 2), torch.randn(3, 2)) + program = _program_for_cuda_passes(model, inputs) + self.assertEqual( + duplicate_constant_node(program, "b_freqs"), {"b_freqs_copy_0"} + ) + ReplaceComplexWithRealPass()(program) + for node in program.graph.nodes: + val = node.meta.get("val") + self.assertFalse(isinstance(val, torch.Tensor) and val.is_complex()) + torch.testing.assert_close(program.module()(*inputs), model(*inputs)) + + def test_leaves_unsupported_programs(self): + """A program with a complex op that the rewrite does not handle keeps + all its complex numbers, so no program is left half rewritten.""" + + class MutatedBuffer(torch.nn.Module): + def __init__(self): + super().__init__() + self.register_buffer("freqs", torch.randn(3, dtype=torch.cfloat)) + + def forward(self, x): + self.freqs.mul_(torch.view_as_complex(x)) + return torch.view_as_real(self.freqs.clone()) + + def square(x): + z = torch.view_as_complex(x) + return torch.view_as_real(z * z) + + def channels_last(x): + z = torch.view_as_complex(x).clone(memory_format=torch.channels_last) + return torch.view_as_real(z) + + def in_branch(x): + return torch.cond(x.sum() > 0, square, torch.neg, (square(x),)) + + pairs = torch.randn(1, 2, 3, 4, 2) + for model, inputs in ( + (_Fn(lambda z: torch.view_as_real(z * z)), (torch.randn(3).cfloat(),)), + (_Fn(lambda x: torch.view_as_real(torch.view_as_complex(x) + 1)), (pairs,)), + (_Fn(square), (pairs.half(),)), + (_Fn(lambda x: torch.view_as_complex(x).float()), (pairs,)), + (_Fn(channels_last), (pairs,)), + (_Fn(in_branch), (pairs,)), + (MutatedBuffer(), (torch.randn(3, 2),)), + ): + program = _program_for_cuda_passes(model, inputs) + code = program.graph_module.code + tensors = {**program.state_dict, **program.constants} + ReplaceComplexWithRealPass()(program) + self.assertEqual(program.graph_module.code, code) + for name, tensor in {**program.state_dict, **program.constants}.items(): + self.assertIs(tensor, tensors[name]) diff --git a/backends/cuda/runtime/shims/memory.cpp b/backends/cuda/runtime/shims/memory.cpp index a9a39a7c7b1..f36b283b4c0 100644 --- a/backends/cuda/runtime/shims/memory.cpp +++ b/backends/cuda/runtime/shims/memory.cpp @@ -46,6 +46,29 @@ using c10::ScalarType; namespace { +// A graph capture cannot record a synchronous copy to or from device memory, +// and such a copy aborts the process when it fails, so a shim that makes one +// refuses first. Info, not Error: the backend then retries the call without a +// graph. An empty tensor copies nothing, so it needs no check. +AOTITorchError refuse_inside_capture( + const SlimTensor* tensor, + const char* what) { +#if defined(CUDA_AVAILABLE) + if (tensor->is_cuda() && tensor->numel() > 0) { + auto stream = getCurrentCUDAStream(tensor->device_index()); + ET_CHECK_OK_OR_RETURN_ERROR(stream.error()); + cudaStreamCaptureStatus capture = cudaStreamCaptureStatusNone; + ET_CUDA_CHECK_OR_RETURN_ERROR( + cudaStreamIsCapturing(stream.get(), &capture)); + if (capture != cudaStreamCaptureStatusNone) { + ET_LOG(Info, "%s: not supported inside a CUDA graph capture", what); + return Error::NotSupported; + } + } +#endif + return Error::Ok; +} + // Reads the one element as T. Dispatch on the dtype the tensor actually holds: // item() copies sizeof(T) bytes and does not check, so asking for the wrong // width reads past a one-element allocation. @@ -67,25 +90,8 @@ AOTITorchError narrow_to(From value, To* ret_value) { template AOTITorchError read_one_element(const SlimTensor* tensor, T* ret_value) { -#if defined(CUDA_AVAILABLE) - // A graph capture cannot record the copy to the host below, and the copy - // aborts the process when it fails, so refuse first. Info, not Error: the - // backend then retries the call without a graph. - if (tensor->is_cuda()) { - auto stream = getCurrentCUDAStream(tensor->device_index()); - ET_CHECK_OK_OR_RETURN_ERROR(stream.error()); - cudaStreamCaptureStatus capture = cudaStreamCaptureStatusNone; - ET_CUDA_CHECK_OR_RETURN_ERROR( - cudaStreamIsCapturing(stream.get(), &capture)); - if (capture != cudaStreamCaptureStatusNone) { - ET_LOG( - Info, - "reading a single element: a device value cannot be read inside a " - "CUDA graph capture"); - return Error::NotSupported; - } - } -#endif + ET_CHECK_OK_OR_RETURN_ERROR( + refuse_inside_capture(tensor, "reading a single element")); switch (tensor->dtype()) { case ScalarType::Byte: return narrow_to(tensor->item(), ret_value); @@ -352,6 +358,8 @@ AOTITorchError aoti_torch_clone_preserve_strides( ret_new_tensor != nullptr, InvalidArgument, "aoti_torch_clone_preserve_strides: ret_new_tensor is null"); + ET_CHECK_OK_OR_RETURN_ERROR( + refuse_inside_capture(self, "aoti_torch_clone_preserve_strides")); *ret_new_tensor = new SlimTensor(self->clone()); return Error::Ok; @@ -365,6 +373,7 @@ AOTITorchError aoti_torch_clone(SlimTensor* self, SlimTensor** ret_new_tensor) { ret_new_tensor != nullptr, InvalidArgument, "aoti_torch_clone: ret_new_tensor is null"); + ET_CHECK_OK_OR_RETURN_ERROR(refuse_inside_capture(self, "aoti_torch_clone")); *ret_new_tensor = new SlimTensor(self->clone()); return Error::Ok; @@ -437,6 +446,9 @@ aoti_torch_copy_(SlimTensor* self, SlimTensor* src, int32_t non_blocking) { ET_CHECK_OR_RETURN_ERROR( src != nullptr, InvalidArgument, "aoti_torch_copy_: src is null"); + ET_CHECK_OK_OR_RETURN_ERROR(refuse_inside_capture(self, "aoti_torch_copy_")); + ET_CHECK_OK_OR_RETURN_ERROR(refuse_inside_capture(src, "aoti_torch_copy_")); + // SlimTensor::copy_() handles: // - Same numel validation // - Same dtype validation diff --git a/backends/cuda/runtime/shims/memory.h b/backends/cuda/runtime/shims/memory.h index ef51ff5eef0..4c4d67b7a53 100644 --- a/backends/cuda/runtime/shims/memory.h +++ b/backends/cuda/runtime/shims/memory.h @@ -150,6 +150,10 @@ aoti_torch_new_tensor_handle(SlimTensor* orig_handle, SlimTensor** new_handle); /** * Clone APIs. + * + * A clone of a CUDA tensor is a synchronous copy, which a CUDA graph capture + * cannot record, so both return Error::NotSupported while the current stream + * is capturing. */ AOTI_SHIM_EXPORT AOTITorchError aoti_torch_clone_preserve_strides( SlimTensor* self, @@ -207,7 +211,9 @@ AOTI_SHIM_EXPORT AOTITorchError aoti_torch_as_strided( * @param self Destination tensor (must not be null) * @param src Source tensor to copy from (must not be null) * @param non_blocking If true, the copy may be asynchronous (currently ignored) - * @return AOTITorchError error code (Error::Ok on success) + * @return Error::Ok on success, Error::NotSupported if either tensor is a CUDA + * tensor and the current stream is capturing a CUDA graph, which cannot record + * the synchronous copy */ AOTI_SHIM_EXPORT AOTITorchError aoti_torch_copy_(SlimTensor* self, SlimTensor* src, int32_t non_blocking); @@ -231,7 +237,8 @@ aoti_torch_item_bool(SlimTensor* tensor, bool* ret_value); * * @param tensor Single-element tensor (must not be null) * @param ret_value Output parameter for the extracted value - * @return AOTITorchError error code (Error::Ok on success) + * @return Error::Ok on success, Error::NotSupported for a CUDA tensor while the + * current stream is capturing a CUDA graph, which cannot record the copy */ AOTI_SHIM_EXPORT AOTITorchError aoti_torch_item_uint8(SlimTensor* tensor, uint8_t* ret_value); diff --git a/backends/cuda/runtime/shims/tests/test_aoti_torch_copy_.cpp b/backends/cuda/runtime/shims/tests/test_aoti_torch_copy_.cpp index 392a3e29244..df67f002184 100644 --- a/backends/cuda/runtime/shims/tests/test_aoti_torch_copy_.cpp +++ b/backends/cuda/runtime/shims/tests/test_aoti_torch_copy_.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -449,6 +450,83 @@ TEST_F(AOTITorchCopySlimTest, CudaToCpu) { EXPECT_EQ(aoti_torch_delete_tensor_object(dst), Error::Ok); } +TEST_F(AOTITorchCopySlimTest, RefusedInsideGraphCapture) { + if (!isCudaAvailable()) { + GTEST_SKIP() << "CUDA not available"; + } + + std::vector sizes = {2, 3}; + Tensor* device = createTestTensor( + sizes, + {}, + static_cast(slim_c10::ScalarType::Float), + static_cast(slim_c10::DeviceType::CUDA), + 0); + Tensor* other_device = createTestTensor( + sizes, + {}, + static_cast(slim_c10::ScalarType::Float), + static_cast(slim_c10::DeviceType::CUDA), + 0); + Tensor* host = createTestTensor(sizes); + std::vector empty_sizes = {0, 3}; + Tensor* empty_device = createTestTensor( + empty_sizes, + {}, + static_cast(slim_c10::ScalarType::Float), + static_cast(slim_c10::DeviceType::CUDA), + 0); + Tensor* other_empty_device = createTestTensor( + empty_sizes, + {}, + static_cast(slim_c10::ScalarType::Float), + static_cast(slim_c10::DeviceType::CUDA), + 0); + ASSERT_NE(device, nullptr); + ASSERT_NE(other_device, nullptr); + ASSERT_NE(host, nullptr); + ASSERT_NE(empty_device, nullptr); + ASSERT_NE(other_empty_device, nullptr); + + cudaStream_t stream = nullptr; + ASSERT_EQ(cudaStreamCreate(&stream), cudaSuccess); + ASSERT_EQ(setCurrentCUDAStream(stream, 0), Error::Ok); + ASSERT_EQ( + cudaStreamBeginCapture(stream, cudaStreamCaptureModeRelaxed), + cudaSuccess); + // A capture cannot record these synchronous copies, so each one refuses + // instead of failing inside the copy. + EXPECT_EQ(aoti_torch_copy_(host, device, 0), Error::NotSupported); + EXPECT_EQ(aoti_torch_copy_(device, host, 0), Error::NotSupported); + EXPECT_EQ(aoti_torch_copy_(other_device, device, 0), Error::NotSupported); + Tensor* clone = nullptr; + EXPECT_EQ(aoti_torch_clone(device, &clone), Error::NotSupported); + EXPECT_EQ( + aoti_torch_clone_preserve_strides(device, &clone), Error::NotSupported); + // A copy in host memory does not touch the stream, and an empty one copies + // nothing. + EXPECT_EQ(aoti_torch_clone(host, &clone), Error::Ok); + if (clone != nullptr) { + EXPECT_EQ(aoti_torch_delete_tensor_object(clone), Error::Ok); + } + EXPECT_EQ(aoti_torch_copy_(empty_device, other_empty_device, 0), Error::Ok); + cudaGraph_t graph = nullptr; + // Nothing reached the stream, so the capture is still valid. + EXPECT_EQ(cudaStreamEndCapture(stream, &graph), cudaSuccess); + if (graph != nullptr) { + EXPECT_EQ(cudaGraphDestroy(graph), cudaSuccess); + } + EXPECT_EQ(aoti_torch_copy_(host, device, 0), Error::Ok); + + clearCurrentCUDAStream(0); + EXPECT_EQ(cudaStreamDestroy(stream), cudaSuccess); + EXPECT_EQ(aoti_torch_delete_tensor_object(device), Error::Ok); + EXPECT_EQ(aoti_torch_delete_tensor_object(other_device), Error::Ok); + EXPECT_EQ(aoti_torch_delete_tensor_object(host), Error::Ok); + EXPECT_EQ(aoti_torch_delete_tensor_object(empty_device), Error::Ok); + EXPECT_EQ(aoti_torch_delete_tensor_object(other_empty_device), Error::Ok); +} + // ============================================================================ // Non-blocking Tests // ============================================================================ diff --git a/backends/cuda/tests/test_cuda_export.py b/backends/cuda/tests/test_cuda_export.py index 7d1a0e91fea..1e9489479bc 100644 --- a/backends/cuda/tests/test_cuda_export.py +++ b/backends/cuda/tests/test_cuda_export.py @@ -556,13 +556,21 @@ def forward(self, q, k, v, cu_seq): (big, big, v, cu_seq, cu_seq, 16, 16), ] table = CudaBackend.get_decomposition_table() - # On ROCm, varlen_attn runs AOTriton, which lines an explicit window up - # with the start of each sequence. The decomposition follows Flash - # attention on CUDA, which lines it up with the end, so compare on CUDA. - if torch.version.hip is None: - for args in cases: - for actual, expected in zip(table[op](*args)[:2], op(*args)[:2]): - torch.testing.assert_close(actual, expected, atol=1e-2, rtol=1e-2) + for args in cases: + # On ROCm, varlen_attn runs AOTriton, which lines an explicit window + # up with the start of each sequence. The decomposition follows + # Flash attention on CUDA, which lines it up with the end. The two + # agree when the query and key sequences line up, as in + # self-attention. + window = args[9] if len(args) > 9 else None + if ( + torch.version.hip is not None + and window is not None + and not torch.equal(args[3], args[4]) + ): + continue + for actual, expected in zip(table[op](*args)[:2], op(*args)[:2]): + torch.testing.assert_close(actual, expected, atol=1e-2, rtol=1e-2) # Flash attention versions differ on this window, since a query sequence # can be longer than max_k + 1. with self.assertRaisesRegex(NotImplementedError, "window"): @@ -1000,6 +1008,79 @@ def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, ...]: for output, expected in zip(outputs, model(x), strict=True): torch.testing.assert_close(output.cpu(), expected) + @unittest.skipIf( + torch.version.hip is not None, + "loads through the Python runtime, which the ROCm build does not include", + ) + def test_complex_rotary_embedding(self): + """Inductor generates no code for complex numbers, so the backend + computes a rotary embedding written with them on real numbers.""" + from executorch.runtime import Runtime + + class ComplexRope(torch.nn.Module): + def __init__(self, precision): + super().__init__() + self.precision = precision + angles = torch.outer(torch.arange(4.0), torch.rand(12)).to(precision) + self.register_buffer( + "freqs", torch.polar(torch.ones_like(angles), angles) + ) + + def forward(self, x, angles): + # A grid of positions takes each axis's part of the frequencies. + rows, cols = self.freqs.split([8, 4], dim=1) + grid = torch.cat( + [ + rows[:2].view(2, 1, -1).expand(2, 2, -1), + cols[:2].view(1, 2, -1).expand(2, 2, -1), + ], + dim=-1, + ).view(4, 1, -1) + spin = torch.polar(angles.abs(), angles) + pairs = torch.view_as_complex( + x.to(self.precision).unflatten(-1, (-1, 2)) + ) + rotated = torch.view_as_real(pairs * grid * spin.unsqueeze(1) * 0.5) + # A view_as_real can also be an output of the program, and two + # of one complex tensor become one output in two slots. + return ( + rotated.flatten(-2).type_as(x), + torch.view_as_real(spin), + torch.view_as_real(spin), + ) + + angles = torch.randn(4, 12) + # Rotary embeddings are often computed in float64, so in complex128. + for dtype, precision in ( + (torch.float32, torch.float32), + (torch.bfloat16, torch.float32), + (torch.float32, torch.float64), + ): + with self.subTest(dtype=dtype, precision=precision): + model = ComplexRope(precision) + x = torch.randn(4, 3, 24, dtype=dtype) + program = self._export_to_cuda_with_lower( + model, (x, angles) + ).to_executorch() + with tempfile.TemporaryDirectory() as directory: + path = os.path.join(directory, "model.pte") + with open(path, "wb") as f: + program.write_to_file(f) + program.write_tensor_data_to_file(directory) + (data,) = [ + os.path.join(directory, name) + for name in os.listdir(directory) + if name.endswith(".ptd") + ] + method = ( + Runtime.get() + .load_program(path, data_path=data) + .load_method("forward") + ) + outputs = method.execute([x, angles]) + for output, expected in zip(outputs, model(x, angles), strict=True): + torch.testing.assert_close(output.cpu(), expected) + def test_device_info_propagated_to_cuda_delegate_outputs(self): """ Verify that, for a CUDA-delegated graph, every memory-planned tensor's diff --git a/backends/cuda/tests/test_output_stride_rearrange.py b/backends/cuda/tests/test_output_stride_rearrange.py index a53156cd3f0..176acd089e5 100644 --- a/backends/cuda/tests/test_output_stride_rearrange.py +++ b/backends/cuda/tests/test_output_stride_rearrange.py @@ -316,6 +316,17 @@ def forward(self, x, mask): return self.calls.clone(), x[mask] +class _ConstantRow(nn.Module): + """AOTI returns a part of a constant through aoti_torch_clone.""" + + def __init__(self): + super().__init__() + self.register_buffer("table", torch.randn(3, 4)) + + def forward(self, x): + return self.table[1], x * 2 + + class _HostFlag(nn.Module): """A torch.cond on a flag the program keeps in host memory and updates on every call.""" @@ -531,6 +542,20 @@ def test_mask_retry_updates_state_once(self): self.assertIsInstance(out, torch.Tensor) torch.testing.assert_close(out, torch.tensor([i + 1.0])) + def test_constant_copy_runs_without_a_graph(self): + # A capture cannot record the copy that returns a part of a constant, + # so the capture call has to run without a graph. + torch.manual_seed(0) + module = _ConstantRow().eval() + inputs = [torch.randn(4) for _ in range(6)] + with _RuntimeLog() as log: + outputs = self._run_with_cuda_graph(module, (inputs[0],), inputs) + self.assertEqual(log.text.count("capture failed for 'forward'"), 1) + for i, out in enumerate(outputs): + with self.subTest(call=i): + self.assertIsInstance(out, torch.Tensor) + torch.testing.assert_close(out, module.table[1]) + def test_output_shape_changes_at_capture(self): torch.manual_seed(0) # The capture call has a larger batch than the warmup calls before it. From bd0fcde258ed938af089d3851820000da000bf7a Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Tue, 3 Nov 2026 13:08:48 -0800 Subject: [PATCH 023/128] XNNPACK: fix wrong outputs when BatchNorm constants share a name, a bf16 matmul load crash, and skipped bmm tests (#24504) XNNPACK lowering can give silently wrong results when one Linear or Conv is used twice and each result goes into its own BatchNorm: def forward(self, x, y): return self.bn1(self.linear(x)), self.bn2(self.linear(y)) When the BatchNorms are folded into the Linear, both folds ask for a constant with the same name, built only from the Linear weight name. The helper that creates constants returns the existing one when a name is taken, so the second output uses the first BatchNorm's statistics. The same thing happens when XNNPACK turns standalone BatchNorms with affine=False into depthwise convolutions: the names came from the BatchNorm weight, which they do not have, so all of them shared one name. The commits: 1. Fold names now include the BatchNorm node, so each fold gets its own weight and bias. Models with one BatchNorm per Linear or Conv give the same .pte as before. Vulkan uses the same pass and gets the fix too. 2. Depthwise convolution names for a standalone BatchNorm now come from the BatchNorm node, which is unique. 3. Loading a bf16 batch matrix multiply or linear whose output tensor has no dims vector no longer crashes. The dims are read the same way defineTensor() reads them. 4. backends/xnnpack/test/ops/test_bmm.py was skipped in pytest.ini and pytest-windows.ini. The file passes now, so the skip is removed and test_bf16_bmm runs in CI. Tested on Linux x86_64: - New tests for 1 and 2 fail before the fix and pass after it, including a lowered and serialized run. - A new C++ test for 3 crashes before the fix and passes after it. - test_bmm.py passes; test_bf16_bmm fails on a build from before the bf16 bmm fix. - backends/xnnpack/test/passes and backends/xnnpack/test/ops show no new failures. - resnet18, mobilenet_v2, mobilenet_v3_small and efficientnet_b0 lower to byte-identical .pte files before and after. macOS and Windows were not run locally; the unit test jobs here cover them. --- .../xnnpack/_passes/decompose_batch_norm.py | 10 ++-- backends/xnnpack/_passes/fuse_batch_norm.py | 9 ++-- backends/xnnpack/runtime/XNNCompiler.cpp | 6 ++- .../test/passes/test_batch_norm_fusion.py | 32 ++++++++++++ .../test/passes/test_decompose_batch_norm.py | 20 ++++++++ .../xnnpack/test/runtime/test_xnnexecutor.cpp | 50 +++++++++++++++++++ pytest-windows.ini | 1 - pytest.ini | 1 - 8 files changed, 117 insertions(+), 12 deletions(-) diff --git a/backends/xnnpack/_passes/decompose_batch_norm.py b/backends/xnnpack/_passes/decompose_batch_norm.py index 683682e9b23..e034b24c55d 100644 --- a/backends/xnnpack/_passes/decompose_batch_norm.py +++ b/backends/xnnpack/_passes/decompose_batch_norm.py @@ -13,7 +13,6 @@ from executorch.backends.xnnpack.utils.utils import ( check_or_raise, get_param_tensor, - get_tensor_name, is_param_node, ) from executorch.exir.backend.utils import WhyNoPartition @@ -184,11 +183,10 @@ def replace_bn_node_with_conv( new_weight_shape = [weight.shape[0], 1] + [1] * spatial_dims weight = weight.reshape(new_weight_shape) - # Generate names for the new weight and bias parameters based on the original - # batch norm gamma parameter name. - gamma_name = get_tensor_name(self.exported_program, bn_node.args[1]) - weight_name = (gamma_name + "_decomposed_bn_weight").replace(".", "_") - bias_name = (gamma_name + "_decomposed_bn_bias").replace(".", "_") + # Name the new parameters after the batch norm node. The gamma name does not + # work: it is empty when affine=False, so all such batch norms would share it. + weight_name = f"{bn_node.name}_decomposed_bn_weight" + bias_name = f"{bn_node.name}_decomposed_bn_bias" # Insert the new weight and bias as constant placeholders in the graph. with graph_module.graph.inserting_before(bn_node.args[1]): diff --git a/backends/xnnpack/_passes/fuse_batch_norm.py b/backends/xnnpack/_passes/fuse_batch_norm.py index 9e383a77c5b..6fdc961e8b8 100644 --- a/backends/xnnpack/_passes/fuse_batch_norm.py +++ b/backends/xnnpack/_passes/fuse_batch_norm.py @@ -238,13 +238,16 @@ def _fuse_ops( # Otherwise, this is a linear node. fused_weight, fused_bias = fuse_linear_bn_weights(*fuse_args) - fused_weight_name = (input_node_weight_name + "_fused_bn").replace(".", "_") + # Several convolutions or linears can share one weight and fold different + # batch norms, so the names must include the batch norm. + fused_suffix = f"_{bn.name}_fused_bn" + fused_weight_name = (input_node_weight_name + fused_suffix).replace(".", "_") if input_node_bias_name == "": - fused_bias_name = (input_node_weight_name + "_bias_fused_bn").replace( + fused_bias_name = (input_node_weight_name + "_bias" + fused_suffix).replace( ".", "_" ) else: - fused_bias_name = (input_node_bias_name + "_fused_bn").replace(".", "_") + fused_bias_name = (input_node_bias_name + fused_suffix).replace(".", "_") # Modify the graph by updating the weight and bias of the conv or linear op # with the fused weight and bias params, and replacing all the users diff --git a/backends/xnnpack/runtime/XNNCompiler.cpp b/backends/xnnpack/runtime/XNNCompiler.cpp index 32be6ae872d..b9797949a01 100644 --- a/backends/xnnpack/runtime/XNNCompiler.cpp +++ b/backends/xnnpack/runtime/XNNCompiler.cpp @@ -844,7 +844,11 @@ Error defineBf16MatMulResult( output->datatype() != DataType::xnn_datatype_bf16) { return Error::Ok; } - std::vector dims = flatbufferDimsToVector(output->dims()); + // Like defineTensor(), treat a null dims vector as rank 0. + std::vector dims; + if (output->dims() != nullptr) { + dims = flatbufferDimsToVector(output->dims()); + } xnn_status status = xnn_define_tensor_value( subgraph_ptr, xnn_datatype_fp32, diff --git a/backends/xnnpack/test/passes/test_batch_norm_fusion.py b/backends/xnnpack/test/passes/test_batch_norm_fusion.py index 519cf94243a..54084951bb5 100644 --- a/backends/xnnpack/test/passes/test_batch_norm_fusion.py +++ b/backends/xnnpack/test/passes/test_batch_norm_fusion.py @@ -55,6 +55,19 @@ def forward(self, x): y = y + y return self.bn(y) + class ModelSharedLinearTwoBN(torch.nn.Module): + def __init__(self, bias: bool): + super().__init__() + self.linear = torch.nn.Linear(8, 4, bias=bias) + self.bn1 = torch.nn.BatchNorm1d(4) + self.bn2 = torch.nn.BatchNorm1d(4) + for bn in (self.bn1, self.bn2): + bn.running_mean.uniform_(-1, 1) + bn.running_var.uniform_(0.5, 2) + + def forward(self, x, y): + return self.bn1(self.linear(x)), self.bn2(self.linear(y)) + class ModelConv3dBN(torch.nn.Module): def __init__( self, @@ -135,6 +148,25 @@ def test_fp32_linear_batch_norm_fusion(self): .run_method_and_compare_outputs() ) + def test_fp32_shared_linear_two_batch_norms_fusion(self): + """ + One linear feeds two batch norms with different statistics, so each fold + must get its own fused weight and bias. + """ + for bias in [True, False]: + ( + Tester( + self.ModelSharedLinearTwoBN(bias).eval(), + (torch.randn(2, 8), torch.randn(2, 8)), + ) + .export() + .to_edge_transform_and_lower() + .check_count({self.bn_name: 0}) + .to_executorch() + .serialize() + .run_method_and_compare_outputs() + ) + def test_fp32_linear_batch_norm_on_3d_output(self): """ BatchNorm1d normalizes dim 1. When the linear output is 3-D, dim 1 is not the diff --git a/backends/xnnpack/test/passes/test_decompose_batch_norm.py b/backends/xnnpack/test/passes/test_decompose_batch_norm.py index d2897258fcd..21c4e908e8a 100644 --- a/backends/xnnpack/test/passes/test_decompose_batch_norm.py +++ b/backends/xnnpack/test/passes/test_decompose_batch_norm.py @@ -147,6 +147,26 @@ def test_fp32_batch_norm_nchw_non_affine(self): ) self._validate_decomposition(tester.get_artifact(), torch.float32, 3, 2) + def test_fp32_two_non_affine_batch_norms(self): + """Test that each non-affine BatchNorm2d gets its own convolution weights.""" + model = torch.nn.Sequential( + self.BatchNorm2d(3, affine=False), + torch.nn.ReLU(), + self.BatchNorm2d(3, affine=False), + ).eval() + ( + Tester( + model, + (torch.randn(2, 3, 4, 4),), + ) + .export() + .to_edge() + .run_passes(self.PassStage) + .check_count({self.conv_name: 2}) + .check_not([self.bn_name]) + .run_method_and_compare_outputs() + ) + def _validate_decomposition( self, edge_manager: EdgeProgramManager, diff --git a/backends/xnnpack/test/runtime/test_xnnexecutor.cpp b/backends/xnnpack/test/runtime/test_xnnexecutor.cpp index 5754da31e39..219b54247db 100644 --- a/backends/xnnpack/test/runtime/test_xnnexecutor.cpp +++ b/backends/xnnpack/test/runtime/test_xnnexecutor.cpp @@ -155,6 +155,56 @@ TEST_F(XNNPackedLhsTest, RuntimeSpecOverridesGlobalDefault) { } } +TEST(XNNCompilerTest, Bf16BatchMatrixMultiplyOutputWithoutDims) { + runtime::runtime_init(); + auto* backend = runtime::get_backend_class(xnnpack_backend_key); + ASSERT_NE(backend, nullptr); + + flatbuffers::FlatBufferBuilder builder; + const std::vector> input_dims = {{2, 3, 4}, {2, 4, 6}}; + std::vector> values; + for (uint32_t id = 0; id < 3; ++id) { + flatbuffers::Offset> dims; + if (id < 2) { + dims = builder.CreateVector(input_dims[id]); + } + const auto tensor = fb_xnnpack::CreateXNNTensorValue( + builder, + fb_xnnpack::XNNDatatype::xnn_datatype_bf16, + id < 2 ? 3 : 0, + dims, + 0, + id, + id == 2 ? XNN_VALUE_FLAG_EXTERNAL_OUTPUT + : XNN_VALUE_FLAG_EXTERNAL_INPUT, + id); + values.push_back(fb_xnnpack::CreateXValue( + builder, fb_xnnpack::XValueUnion::XNNTensorValue, tensor.Union())); + } + const auto bmm = fb_xnnpack::Create_XNNNode2x1(builder, 0, 1, 2); + const auto node = fb_xnnpack::CreateXNode( + builder, fb_xnnpack::XNodeUnion::XNNBatchMatrixMultiply, bmm.Union()); + const auto graph = fb_xnnpack::CreateXNNGraph( + builder, + builder.CreateString("0"), + builder.CreateVector(&node, 1), + builder.CreateVector(values), + 3, + builder.CreateVector({0, 1}), + builder.CreateVector({2})); + fb_xnnpack::FinishXNNGraphBuffer(builder, graph); + + std::array memory{}; + runtime::MemoryAllocator allocator(memory.size(), memory.data()); + runtime::BackendInitContext context(&allocator); + runtime::FreeableBuffer processed( + builder.GetBufferPointer(), builder.GetSize(), nullptr); + // XNNPACK works out the output shape itself, so the graph still loads. + auto handle = backend->init(context, &processed, {}); + ASSERT_TRUE(handle.ok()); + backend->destroy(handle.get()); +} + } // namespace executorch::backends::xnnpack::delegate TEST(XNNExecutorTest, ArgumentWithTooManyDimensions) { diff --git a/pytest-windows.ini b/pytest-windows.ini index 26a0ee6dabc..16ccd186853 100644 --- a/pytest-windows.ini +++ b/pytest-windows.ini @@ -50,7 +50,6 @@ addopts = backends/test/suite/tests # backends/xnnpack backends/xnnpack/test/ops - --ignore=backends/xnnpack/test/ops/test_bmm.py --ignore=backends/xnnpack/test/ops/test_conv2d.py --ignore=backends/xnnpack/test/ops/test_linear.py --ignore=backends/xnnpack/test/ops/test_sdpa.py diff --git a/pytest.ini b/pytest.ini index 33be7d530ba..cc8a6465a3d 100644 --- a/pytest.ini +++ b/pytest.ini @@ -13,7 +13,6 @@ addopts = --ignore-glob=backends/arm/**/* # Ignore failing xnnpack tests --ignore=backends/xnnpack/test/ops/test_linear.py - --ignore=backends/xnnpack/test/ops/test_bmm.py --ignore=backends/xnnpack/test/quantizer/test_xnnpack_quantizer.py # Ignore backends/test root - WIP testing infra, see https://github.com/pytorch/executorch/discussions/11140 --ignore=backends/test From 97e9fbb243bdce887a2441fe84a03a2abfa556f6 Mon Sep 17 00:00:00 2001 From: Richard Barnes Date: Tue, 3 Nov 2026 13:40:17 -0800 Subject: [PATCH 024/128] Remove dead CUDA 11 select arm Summary: Drop the `//third-party/cuda:windows-cuda-11` arm from the Vulkan platform flags. No CUDA 11 toolkit or mode remains, so it never matches; the `ovr_config//os:windows` arm already sets the same define. Part of removing CUDA versions below 13 (T294426402). ___ landed-with-radar-review Reviewed By: dtolnay Differential Revision: D129309847 Pull Request resolved: https://github.com/pytorch/executorch/pull/24513 --- backends/vulkan/targets.bzl | 3 --- 1 file changed, 3 deletions(-) diff --git a/backends/vulkan/targets.bzl b/backends/vulkan/targets.bzl index aaa96eccb63..7fb133d994b 100644 --- a/backends/vulkan/targets.bzl +++ b/backends/vulkan/targets.bzl @@ -50,9 +50,6 @@ def get_vulkan_preprocessor_flags(no_volk, is_fbcode): "ovr_config//os:android": android_flags, "ovr_config//os:macos": mac_flags, }) + select({ - "//third-party/cuda:windows-cuda-11": [ - "-DVK_USE_PLATFORM_WIN32_KHR", - ], "DEFAULT": [], "ovr_config//os:android": [ "-DVK_USE_PLATFORM_ANDROID_KHR", From 27de6dba209bf759ef554c3469939cd92f9e161d Mon Sep 17 00:00:00 2001 From: Elena Zhelezina Date: Tue, 3 Nov 2026 21:46:22 +0000 Subject: [PATCH 025/128] Arm backend: Fix provenance for rSub.Scalar (#24500) Another fix for the provenance metadata. cc @digantdesai @freddan80 @per @zingo @oscarandersson8218 @mansnils @Sebastian-Larsson @robell @rascani Signed-off-by: Elena Zhelezina --- .../arm/_passes/scalars_to_attribute_pass.py | 15 ++- .../passes/test_scalars_to_attribute_pass.py | 120 ++++++++++++++++++ 2 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 backends/arm/test/passes/test_scalars_to_attribute_pass.py diff --git a/backends/arm/_passes/scalars_to_attribute_pass.py b/backends/arm/_passes/scalars_to_attribute_pass.py index 3c02ff2b7c5..51cbe6adce1 100644 --- a/backends/arm/_passes/scalars_to_attribute_pass.py +++ b/backends/arm/_passes/scalars_to_attribute_pass.py @@ -8,7 +8,10 @@ import torch from executorch.backends.arm._passes import ArmPass -from executorch.backends.arm._passes.arm_pass_utils import get_first_fake_tensor +from executorch.backends.arm._passes.arm_pass_utils import ( + create_node, + get_first_fake_tensor, +) from executorch.backends.arm._passes.match_arg_ranks_pass import MatchArgRanksPass from executorch.exir.graph_module import get_cond_while_submodules from executorch.exir.pass_base import ExportPass, PassResult @@ -113,11 +116,15 @@ def _convert_scalar_args( if n.target == torch.ops.aten.rsub.Scalar: with graph_module.graph.inserting_after(n): reversed_args = (n.args[1], n.args[0]) - sub = graph_module.graph.create_node( - "call_function", torch.ops.aten.sub.Tensor, reversed_args, {} + # Preserve rsub provenance and transform metadata. + # create_node also clears stale input/output qparams. + sub = create_node( + graph=graph_module.graph, + op_target=torch.ops.aten.sub.Tensor, + args=reversed_args, + from_node=n, ) n.replace_all_uses_with(sub) - sub.meta["val"] = n.meta["val"] graph_module.graph.erase_node(n) modified = True return modified diff --git a/backends/arm/test/passes/test_scalars_to_attribute_pass.py b/backends/arm/test/passes/test_scalars_to_attribute_pass.py new file mode 100644 index 00000000000..48c65118df5 --- /dev/null +++ b/backends/arm/test/passes/test_scalars_to_attribute_pass.py @@ -0,0 +1,120 @@ +# Copyright 2026 Arm Limited and/or its affiliates. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +from typing import Any + +import pytest +import torch +from executorch.backends.arm._passes.scalars_to_attribute_pass import ( + ScalarsToAttributePass, +) +from executorch.backends.arm.constants import DISALLOW_TFA_META_KEY +from executorch.backends.cortex_m.quantizer.node_finders import ( + ModuleNameNodeFinder, + ModuleTypeNodeFinder, +) +from torch.fx import GraphModule, Node + + +class ReverseSubtract(torch.nn.Module): + """A real submodule so torch.export records module provenance.""" + + def __init__(self, scalar: float = 2.5) -> None: + super().__init__() + self.scalar = scalar + + def forward(self, x: torch.Tensor) -> torch.Tensor: + return torch.rsub(x, self.scalar) + + +class ReverseSubtractModel(torch.nn.Module): + def __init__(self, scalar: float = 2.5) -> None: + super().__init__() + self.reverse_sub = ReverseSubtract(scalar) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + return self.reverse_sub(x) + + +def _rewrite_rsub( + scalar: float = 2.5, +) -> tuple[ + ReverseSubtractModel, + tuple[torch.Tensor], + GraphModule, + Node, + dict[str, Any], +]: + model = ReverseSubtractModel(scalar).eval() + inputs = (torch.randn(2, 3),) + graph_module = torch.export.export(model, inputs).module() + + source_nodes = [ + node + for node in graph_module.graph.nodes + if node.target == torch.ops.aten.rsub.Scalar + ] + assert len(source_nodes) == 1, "Expected one exported rsub.Scalar" + source = source_nodes[0] + assert source.meta.get("nn_module_stack"), "Export must preserve module stack" + + # Test propagation beyond meta["val"]. False does not disable this TFA pass. + source.meta[DISALLOW_TFA_META_KEY] = False + source.meta["_rsub_test_provenance"] = "source-rsub" + source_meta = dict(source.meta) + + result = ScalarsToAttributePass(tfa_pass=True)(graph_module) + assert result is not None, "Scalar conversion must return a PassResult" + assert result.modified, "rsub.Scalar must be converted" + transformed = result.graph_module + + replacements = [ + node + for node in transformed.graph.nodes + if node.target == torch.ops.aten.sub.Tensor + ] + assert len(replacements) == 1, "Expected one replacement sub.Tensor" + assert not any( + node.target == torch.ops.aten.rsub.Scalar for node in transformed.graph.nodes + ) + return model, inputs, transformed, replacements[0], source_meta + + +def test_rsub_scalar_decomposition_preserves_module_metadata() -> None: + """The replacement sub.Tensor must inherit rsub's nn_module_stack.""" + _, _, _, replacement, source_meta = _rewrite_rsub() + assert replacement.meta.get("nn_module_stack") == source_meta["nn_module_stack"] + + +@pytest.mark.parametrize("selector", ["module_type", "module_name"]) +def test_rsub_scalar_decomposition_remains_selectable(selector: str) -> None: + """Module-type/name filters used by Arm selective quantization still + match. + """ + _, _, transformed, replacement, _ = _rewrite_rsub() + if selector == "module_type": + selected = set(ModuleTypeNodeFinder(ReverseSubtract).find_nodes(transformed)) + else: + selected = set(ModuleNameNodeFinder("reverse_sub").find_nodes(transformed)) + assert replacement in selected + + +def test_rsub_scalar_decomposition_preserves_other_metadata() -> None: + """A metadata-preserving rewrite must not copy only the fake tensor + value. + """ + _, _, _, replacement, source_meta = _rewrite_rsub() + assert replacement.meta.get("_rsub_test_provenance") == "source-rsub" + assert DISALLOW_TFA_META_KEY in replacement.meta + assert replacement.meta[DISALLOW_TFA_META_KEY] is False + assert replacement.meta["val"].shape == source_meta["val"].shape + assert replacement.meta["val"].dtype == source_meta["val"].dtype + + +@pytest.mark.parametrize("scalar", [2.5, -1.25, 0.0]) +def test_rsub_scalar_decomposition_numerical_equivalence(scalar: float) -> None: + """Swapping the operand order must retain normal rsub semantics.""" + model, inputs, transformed, _, _ = _rewrite_rsub(scalar) + torch.testing.assert_close(transformed(*inputs), model(*inputs)) From 892e50425f5a3e90ff8d3c8802fdfb31f8599518 Mon Sep 17 00:00:00 2001 From: roman-janik-nxp Date: Tue, 3 Nov 2026 23:27:14 +0100 Subject: [PATCH 026/128] NXP backend: Enable MobileNetV3 model (#24307) ### Summary Add support for MobileNetV3 model. ### Test plan Unit tests provided. cc @robert-kalmar @JakeStevens @digantdesai @rascani --- .../tests/generic_tests/test_aot_example.py | 4 +- .../nxp/tests/models/test_mobilenet_v3.py | 102 ++++++++++++++++++ examples/nxp/README.md | 4 + examples/nxp/models/mobilenet_v3.py | 21 ++++ 4 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 backends/nxp/tests/models/test_mobilenet_v3.py create mode 100644 examples/nxp/models/mobilenet_v3.py diff --git a/backends/nxp/tests/generic_tests/test_aot_example.py b/backends/nxp/tests/generic_tests/test_aot_example.py index 4beded3403a..e9b1bf06c72 100644 --- a/backends/nxp/tests/generic_tests/test_aot_example.py +++ b/backends/nxp/tests/generic_tests/test_aot_example.py @@ -125,7 +125,7 @@ def _assert_profiling( def test_aot_example__mobilenet_v2(): - """Test that mobilenet can be lowered to Neutron backend via `aot_neutron_compile.py` and all ops are delegated.""" + """Test that MobileNetV2 can be lowered to Neutron backend via `aot_neutron_compile.py` and all ops are delegated.""" num_random_samples = 1 @@ -154,7 +154,7 @@ def test_aot_example__mobilenet_v2(): def test_aot_example__mobilenet_v2__profiling(): - """Test that mobilenet_v2 can be lowered to Neutron backend via `aot_neutron_compile.py`, all ops are delegated, + """Test that MobileNetV2 can be lowered to Neutron backend via `aot_neutron_compile.py`, all ops are delegated, the output model is profilable and ETRecord is generated properly.""" num_random_samples = 1 diff --git a/backends/nxp/tests/models/test_mobilenet_v3.py b/backends/nxp/tests/models/test_mobilenet_v3.py new file mode 100644 index 00000000000..a981a28d918 --- /dev/null +++ b/backends/nxp/tests/models/test_mobilenet_v3.py @@ -0,0 +1,102 @@ +# Copyright 2026 NXP +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +from functools import partial + +import numpy as np + +# noinspection PyUnusedImports +import pytest +import torch + +from executorch.backends.nxp.tests.dataset_creator import ( + FromCalibrationDataDatasetCreator, +) +from executorch.backends.nxp.tests.executorch_pipeline import ModelInputSpec +from executorch.backends.nxp.tests.graph_verifier import ( + BaseGraphVerifier, + NonDelegatedNode, +) +from executorch.backends.nxp.tests.model_output_comparator import ( + NumericalStatsOutputComparator, +) +from executorch.backends.nxp.tests.nsys_testing import lower_run_compare +from executorch.backends.nxp.tests.use_qat import * # noqa F403 +from executorch.examples.nxp.models.mobilenet_v3 import MobileNetV3 + +BOUNDS_MSE = { + "PTQ": {"channels-last": 7e-03, "channels-first": 8e-03}, + "QAT": {"channels-last": 6e-03, "channels-first": 3e-03}, +} + + +@pytest.fixture(autouse=True) +def reseed_model_per_test_run(): + torch.manual_seed(23) + np.random.seed(23) + + +@pytest.mark.parametrize("channels_last", [False, True]) +def test_mobilenet_v3_mse_cpu_vs_npu( + mocker, + request, + channels_last, + use_qat, +): + num_samples = 1 + + mobilenet_v3 = MobileNetV3( + num_samples=num_samples, + use_random_dataset=True, + balanced_dataset=False, + ) + model = mobilenet_v3.get_eager_model() + dataset = mobilenet_v3.dataset + labels = mobilenet_v3.labels + + dataset_creator = FromCalibrationDataDatasetCreator( + dataset, num_examples=num_samples, idx_to_label=labels + ) + + input_spec = ModelInputSpec(mobilenet_v3.input_shape) + if channels_last: + model.to(memory_format=torch.channels_last) + input_spec.dim_order = torch.channels_last + quant_type_key = "QAT" if use_qat else "PTQ" + format_key = "channels-last" if channels_last else "channels-first" + + mse = BOUNDS_MSE[quant_type_key][format_key] + comparator = NumericalStatsOutputComparator( + max_mse_error=mse, is_classification_task=True + ) + model_verifier = BaseGraphVerifier( + 1, + [ + NonDelegatedNode("aten_add_tensor", 13), + NonDelegatedNode("aten_div_tensor", 9), + NonDelegatedNode("aten_clamp_default", 18), + ], + ) + train_fn = ( + partial(mobilenet_v3.train_model_fn, channels_last=channels_last) + if use_qat + else None + ) + + ref_input_spec = ModelInputSpec(input_spec.shape) + ref_input_spec.dim_order = torch.contiguous_format + + lower_run_compare( + model, + [input_spec], + model_verifier, + request, + dataset_creator=dataset_creator, + output_comparator=comparator, + reference_input_spec=[ref_input_spec], + mocker=mocker, + use_qat=use_qat, + train_fn=train_fn, + ) diff --git a/examples/nxp/README.md b/examples/nxp/README.md index 247c7253756..40abf65b6a8 100644 --- a/examples/nxp/README.md +++ b/examples/nxp/README.md @@ -23,6 +23,10 @@ $ ./examples/nxp/setup.sh ## Supported models * CifarNet * MobileNetV2 +* MLPerfTinyAnomalyDetection +* MLPerfTinyImageClassification +* MLPerfTinyKeywordSpotting +* MLPerfTinyVisualWakeWords ## PyTorch Model Delegation to Neutron Backend First we will start with an example script converting the model. This example show the CifarNet model preparation. diff --git a/examples/nxp/models/mobilenet_v3.py b/examples/nxp/models/mobilenet_v3.py new file mode 100644 index 00000000000..43494c34f44 --- /dev/null +++ b/examples/nxp/models/mobilenet_v3.py @@ -0,0 +1,21 @@ +# Copyright 2026 NXP +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import torch + +from executorch.examples.models.mobilenet_v3 import MV3Model +from executorch.examples.nxp.models.mobilenet_v2 import MobileNetV2 + + +class MobileNetV3(MobileNetV2): + """ + MobileNet V3 model. Shares most parameters with MobileNet V2. + + Calibration data for MobileNet V2 and V3 can be generated by backends/nxp/tests/prepare_calibration_data_mobilenet_v2.py + from Imagenette dataset, a subset of 10 classes from ImageNet dataset the model is trained on. + """ + + def _init_eager_model(self) -> torch.nn.Module: + return MV3Model().get_eager_model().eval() From 33de5051df28079a65be1023bd2b4d760741b796 Mon Sep 17 00:00:00 2001 From: mcremon-meta <134334895+mcremon-meta@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:29:29 -0800 Subject: [PATCH 027/128] Remove the unused iDMA ExecuTorch ops Summary: No active compiler pass emits the iDMA operators. Remove `cadence::idma_copy` and `cadence::idma_wait` (functional and `.out` forms), their load/store intermediates, registrations, reference implementations, memory-planning handling, generic kernels, and registration test. Differential Revision: D129417305 Pull Request resolved: https://github.com/pytorch/executorch/pull/24528 --- backends/cadence/aot/BUCK | 13 ---- backends/cadence/aot/memory_constraints.py | 34 +---------- backends/cadence/aot/memory_planning.py | 30 +--------- backends/cadence/aot/ops_registrations.py | 60 ------------------- backends/cadence/aot/ref_implementations.py | 20 ------- backends/cadence/aot/tests/test_idma_ops.py | 31 ---------- .../generic/operators/op_idma_copy.cpp | 52 ---------------- .../cadence/generic/operators/op_idma_copy.h | 27 --------- .../generic/operators/op_idma_wait.cpp | 47 --------------- .../cadence/generic/operators/op_idma_wait.h | 26 -------- .../cadence/generic/operators/targets.bzl | 26 -------- 11 files changed, 3 insertions(+), 363 deletions(-) delete mode 100644 backends/cadence/aot/tests/test_idma_ops.py delete mode 100644 backends/cadence/generic/operators/op_idma_copy.cpp delete mode 100644 backends/cadence/generic/operators/op_idma_copy.h delete mode 100644 backends/cadence/generic/operators/op_idma_wait.cpp delete mode 100644 backends/cadence/generic/operators/op_idma_wait.h diff --git a/backends/cadence/aot/BUCK b/backends/cadence/aot/BUCK index d41fba2e71f..ec53d17d765 100644 --- a/backends/cadence/aot/BUCK +++ b/backends/cadence/aot/BUCK @@ -649,19 +649,6 @@ fbcode_target(_kind = python_unittest, ], ) -fbcode_target(_kind = python_unittest, - name = "test_idma_ops", - srcs = [ - "tests/test_idma_ops.py", - ], - deps = [ - "//executorch/backends/test:graph_builder", - "//executorch/backends/cadence/aot:ops_registrations", - "//executorch/runtime:runtime", - "//later:lib", - ], -) - fbcode_target(_kind = runtime.python_library, name = "weight_packing", srcs = [ diff --git a/backends/cadence/aot/memory_constraints.py b/backends/cadence/aot/memory_constraints.py index d538d273160..dbf152b4093 100644 --- a/backends/cadence/aot/memory_constraints.py +++ b/backends/cadence/aot/memory_constraints.py @@ -690,36 +690,6 @@ def compute_slice_and_select_loc_constraints( ] -class GenerateIdmaConstraints(PassBase): - """Generate constraints for idma ops.""" - - def __init__(self, constraint: MemConstraints) -> None: - self.constraint = constraint - - def call(self, graph_module: torch.fx.GraphModule) -> Optional[PassResult]: - for node in graph_module.graph.find_nodes( - op="call_function", target=torch.ops.cadence.idma_wait.out - ): - # This is just an alias op. - self.constraint.add_relative_placement_constraint(node.args[0], node) - - for node in graph_module.graph.find_nodes( - op="call_function", target=torch.ops.cadence.idma_load.out - ): - # TODO: set correct dtcm bank here. - mem_id = 1 - self.constraint.add_absolute_placement_constraint(node, mem_id, None) - - for node in graph_module.graph.find_nodes( - op="call_function", target=torch.ops.cadence.idma_store.out - ): - # TODO: set correct dtcm bank here. - mem_id = 1 - self.constraint.add_absolute_placement_constraint( - node.args[0], mem_id, None - ) - - # The class to generate all the constraints that will be passed on to the memory # planning algorithm. class GenerateMemConstraints: @@ -735,17 +705,15 @@ def __init__( def __call__(self, graph_module: torch.fx.GraphModule) -> PassResult: # Colocation constraints for cat/slice/select no-ops are optimizations; - # the idma and memory-view constraints are always required. + # memory-view constraints are always required. constraint_gen_passes: Sequence[ConstraintsGenPass] = cast( list[ConstraintsGenPass], ( [ - GenerateIdmaConstraints, GenerateMemoryViewConstraints, ] if self.mem_constraints.mode is CompileMode.MINIMAL else [ - GenerateIdmaConstraints, GenerateMemoryViewConstraints, GenerateSliceAndSelectNopConstraints, GenerateCatNopConstraints, diff --git a/backends/cadence/aot/memory_planning.py b/backends/cadence/aot/memory_planning.py index 930cf29deb1..e2e349c9ac9 100644 --- a/backends/cadence/aot/memory_planning.py +++ b/backends/cadence/aot/memory_planning.py @@ -369,29 +369,6 @@ def print_memory_planning_info( ) -class SimplifyIdmaOpsPass(PassBase): - """Replace idma_load and idma_store with idma_copy.""" - - def call(self, graph_module: torch.fx.GraphModule) -> Optional[PassResult]: - modified = False - for node in graph_module.graph.find_nodes( - op="call_function", target=torch.ops.cadence.idma_load.out - ): - modified = True - node.target = torch.ops.cadence.idma_copy.out - node.args = (node.args[0], *node.args[2:]) - - for node in graph_module.graph.find_nodes( - op="call_function", target=torch.ops.cadence.idma_store.out - ): - modified = True - node.target = torch.ops.cadence.idma_copy.out - - graph_module.graph.eliminate_dead_code() - graph_module.recompile() - return PassResult(graph_module, modified) - - class RemoveNopOpsPass(PassBase): """Erase the cat/slice/select nop nodes once memory planning is done. @@ -404,9 +381,8 @@ class RemoveNopOpsPass(PassBase): This has to happen here rather than as its own pass. Before planning the nodes cannot go, because they are what carries the placement constraint; after it nothing may run at all (see the warning in - exir/program/_program.py). Running inside the memory planning pass, in the - slot SimplifyIdmaOpsPass already occupies, is the one point where the - placement is decided but the program is not yet emitted. + exir/program/_program.py). Here placement is decided but the program + is not yet emitted. """ def __init__(self, graph_signature: Optional[ExportGraphSignature] = None) -> None: @@ -545,7 +521,6 @@ def run( mem_planning = MemoryPlanningPass( self.algo, # Always allow lifetime and storage overlap. - # At opt level 0, we need overlap for idma wait. allow_lifetime_and_storage_overlap=True, alloc_graph_input=self.alloc_graph_input, alloc_graph_output=self.alloc_graph_output, @@ -554,7 +529,6 @@ def run( graph_module = PassManager( passes=[ - SimplifyIdmaOpsPass(), RemoveNopOpsPass(graph_signature), ] )(graph_module).graph_module diff --git a/backends/cadence/aot/ops_registrations.py b/backends/cadence/aot/ops_registrations.py index a98f94033f6..8fe83f23314 100644 --- a/backends/cadence/aot/ops_registrations.py +++ b/backends/cadence/aot/ops_registrations.py @@ -547,27 +547,6 @@ def register_fake( "sdpa_bitwise_ring_mask_gen.out(Tensor positions, int sliding_window, int cache_length, *, Tensor(a!) out) -> Tensor(a!)" ) -# Load/store with iDMA. These only exist before memory planning. -# Post memory planning, we check that outputs/inputs for the load/store are in -# DTCM and replace idma_load/idma_store with idma_copy. -lib.define("idma_load(Tensor src, int task_num=0, int channel=0) -> Tensor") -lib.define( - "idma_load.out(Tensor src, int task_num=0, int channel=0, *, Tensor(a!) out) -> Tensor(a!)" -) -lib.define("idma_store(Tensor src, int task_num=0, int channel=0) -> Tensor") -lib.define( - "idma_store.out(Tensor src, int task_num=0, int channel=0, *, Tensor(a!) out) -> Tensor(a!)" -) - -# Non-blocking iDMA copy. -lib.define("idma_copy(Tensor src, int task_num=0, int channel=0) -> Tensor") -lib.define( - "idma_copy.out(Tensor src, int task_num=0, int channel=0, *, Tensor(a!) out) -> Tensor(a!)" -) -# iDMA wait. -lib.define("idma_wait(Tensor src, int task_num=0) -> Tensor") -lib.define("idma_wait.out(Tensor src, int task_num=0, *, Tensor(a!) out) -> Tensor(a!)") - # ------------------------------------ # # Migrated from custom_ops.yaml # # ------------------------------------ # @@ -3291,45 +3270,6 @@ def quantized_rope_rotate_stacked_halves_meta( return rope_rotate_stacked_halves_meta(input, sin_tensor, cos_tensor, pos) -@register_fake("cadence::idma_copy") -def copy_idma_copy_impl( - src: torch.Tensor, - task_num: int = 0, - channel: int = 0, -) -> torch.Tensor: - return src.new_empty(*src.shape, dtype=src.dtype) - - -@register_fake("cadence::idma_wait") -def copy_idma_wait_impl( - src: torch.Tensor, - task_num: int = 0, -) -> torch.Tensor: - return src.new_empty(*src.shape, dtype=src.dtype) - - -@register_fake("cadence::idma_load") -def idma_load_impl( - src: torch.Tensor, - task_num: int = 0, - channel: int = 0, -) -> torch.Tensor: - res = copy_idma_copy_impl(src, task_num, channel) - assert isinstance(res, torch.Tensor) - return res - - -@register_fake("cadence::idma_store") -def idma_store_impl( - src: torch.Tensor, - task_num: int = 0, - channel: int = 0, -) -> torch.Tensor: - res = copy_idma_copy_impl(src, task_num, channel) - assert isinstance(res, torch.Tensor) - return res - - @register_fake("cadence::roi_align_box_processor") def roi_align_box_processor_meta( rois: torch.Tensor, diff --git a/backends/cadence/aot/ref_implementations.py b/backends/cadence/aot/ref_implementations.py index 5a2926e9d18..487d32b5328 100644 --- a/backends/cadence/aot/ref_implementations.py +++ b/backends/cadence/aot/ref_implementations.py @@ -2825,26 +2825,6 @@ def quantized_embedding_byte( return weight[indices] -@impl_tracked(m, "idma_copy") -def idma_copy(src: torch.Tensor, task_num: int = 0, channel: int = 0) -> torch.Tensor: - return src.clone() - - -@impl_tracked(m, "idma_store") -def idma_store(src: torch.Tensor, task_num: int = 0, channel: int = 0) -> torch.Tensor: - return src.clone() - - -@impl_tracked(m, "idma_load") -def idma_load(src: torch.Tensor, task_num: int = 0, channel: int = 0) -> torch.Tensor: - return src.clone() - - -@impl_tracked(m, "idma_wait") -def idma_wait(src: torch.Tensor, task_num: int = 0, channel: int = 0) -> torch.Tensor: - return src.clone() - - @impl_tracked(m, "linalg_svd") def linalg_svd( A: torch.Tensor, diff --git a/backends/cadence/aot/tests/test_idma_ops.py b/backends/cadence/aot/tests/test_idma_ops.py deleted file mode 100644 index ca264f2458a..00000000000 --- a/backends/cadence/aot/tests/test_idma_ops.py +++ /dev/null @@ -1,31 +0,0 @@ -# Copyright (c) Meta Platforms, Inc. and affiliates. - -# pyre-strict - -import executorch.backends.cadence.aot.ops_registrations # noqa -import torch - -from executorch.backends.test.graph_builder import GraphBuilder -from executorch.exir.dialects._ops import ops as exir_ops - -from later.unittest import TestCase - - -class TestIdmaOps(TestCase): - def test_idma_load_store_wait(self) -> None: - """Check that the idma load/store/wait ops are registered correctly.""" - builder = GraphBuilder() - x = builder.placeholder("x", torch.ones(2, 7, dtype=torch.float32)) - load = builder.call_operator( - op=exir_ops.edge.cadence.idma_load.default, args=(x,) - ) - wait = builder.call_operator( - op=exir_ops.edge.cadence.idma_wait.default, args=(load,) - ) - store = builder.call_operator( - op=exir_ops.edge.cadence.idma_store.default, args=(wait,) - ) - wait2 = builder.call_operator( - op=exir_ops.edge.cadence.idma_wait.default, args=(store,) - ) - builder.output([wait2]) diff --git a/backends/cadence/generic/operators/op_idma_copy.cpp b/backends/cadence/generic/operators/op_idma_copy.cpp deleted file mode 100644 index adf03e4f90a..00000000000 --- a/backends/cadence/generic/operators/op_idma_copy.cpp +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. - */ - -#include - -#include -#include // For std::memcpy - -#include -#include -#include - -namespace impl { -namespace generic { -namespace native { - -using ::executorch::aten::Tensor; -using ::executorch::runtime::KernelRuntimeContext; - -// CPU implementation of idma_copy_out using std::memcpy -// This function performs a direct memory copy between tensors -Tensor& idma_copy_out( - KernelRuntimeContext& ctx, - const Tensor& src, - const int64_t - task_num, // Unused in CPU implementation but kept for API compatibility - const int64_t - channel, // Unused in CPU implementation but kept for API compatibility - Tensor& out) { - ET_KERNEL_CHECK( - ctx, - src.dtype() == out.dtype() && src.numel() == out.numel(), - InvalidArgument, - out); - - // Use std::memcpy for direct memory copy - std::memcpy( - out.mutable_data_ptr(), - src.const_data_ptr(), - out.nbytes()); - - return out; -} - -} // namespace native -} // namespace generic -} // namespace impl diff --git a/backends/cadence/generic/operators/op_idma_copy.h b/backends/cadence/generic/operators/op_idma_copy.h deleted file mode 100644 index 06e470bb80c..00000000000 --- a/backends/cadence/generic/operators/op_idma_copy.h +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. - */ - -#pragma once - -#include -#include - -namespace impl { -namespace generic { -namespace native { - -::executorch::aten::Tensor& idma_copy_out( - ::executorch::runtime::KernelRuntimeContext& ctx, - const ::executorch::aten::Tensor& src, - const int64_t task_num, - const int64_t channel, - ::executorch::aten::Tensor& out); - -} // namespace native -} // namespace generic -} // namespace impl diff --git a/backends/cadence/generic/operators/op_idma_wait.cpp b/backends/cadence/generic/operators/op_idma_wait.cpp deleted file mode 100644 index 1c297ecc367..00000000000 --- a/backends/cadence/generic/operators/op_idma_wait.cpp +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. - */ - -#include "executorch/backends/cadence/generic/operators/op_idma_wait.h" - -#include - -#include "executorch/runtime/core/exec_aten/exec_aten.h" -#include "executorch/runtime/core/exec_aten/util/tensor_util.h" -#include "executorch/runtime/kernel/kernel_runtime_context.h" - -namespace impl { -namespace generic { -namespace native { - -using ::executorch::aten::Tensor; -using ::executorch::runtime::KernelRuntimeContext; - -// CPU implementation of idma_wait_out -// Since there's no actual DMA operation in the CPU implementation, -// this is essentially a no-op function that just ensures the output tensor -// has the same content as the input tensor -Tensor& idma_wait_out( - KernelRuntimeContext& ctx, - const Tensor& src, - const int64_t - task_num, // Unused in CPU implementation but kept for API compatibility - Tensor& out) { - ET_KERNEL_CHECK(ctx, src.numel() == out.numel(), InvalidArgument, out); - ET_KERNEL_CHECK(ctx, src.dtype() == out.dtype(), InvalidArgument, out); - ET_KERNEL_CHECK( - ctx, - src.const_data_ptr() == out.const_data_ptr(), - InvalidArgument, - out); - - return out; -} - -} // namespace native -} // namespace generic -} // namespace impl diff --git a/backends/cadence/generic/operators/op_idma_wait.h b/backends/cadence/generic/operators/op_idma_wait.h deleted file mode 100644 index 2426d98dc37..00000000000 --- a/backends/cadence/generic/operators/op_idma_wait.h +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. - */ - -#pragma once - -#include -#include - -namespace impl { -namespace generic { -namespace native { - -::executorch::aten::Tensor& idma_wait_out( - ::executorch::runtime::KernelRuntimeContext& ctx, - const ::executorch::aten::Tensor& src, - const int64_t task_num, - ::executorch::aten::Tensor& out); - -} // namespace native -} // namespace generic -} // namespace impl diff --git a/backends/cadence/generic/operators/targets.bzl b/backends/cadence/generic/operators/targets.bzl index 4cb8841938c..8194dddb214 100644 --- a/backends/cadence/generic/operators/targets.bzl +++ b/backends/cadence/generic/operators/targets.bzl @@ -435,32 +435,6 @@ def define_common_targets(): visibility = ["PUBLIC"], ) - runtime.cxx_library( - name = "op_idma_copy", - srcs = ["op_idma_copy.cpp"], - exported_headers = ["op_idma_copy.h"], - platforms = CXX, - deps = [ - "//executorch/runtime/kernel:kernel_includes", - "//executorch/runtime/core/exec_aten:lib", - "//executorch/runtime/kernel:kernel_runtime_context", - ], - visibility = ["PUBLIC"], - ) - - runtime.cxx_library( - name = "op_idma_wait", - srcs = ["op_idma_wait.cpp"], - exported_headers = ["op_idma_wait.h"], - platforms = CXX, - deps = [ - "//executorch/runtime/kernel:kernel_includes", - "//executorch/runtime/core/exec_aten:lib", - "//executorch/runtime/kernel:kernel_runtime_context", - ], - visibility = ["PUBLIC"], - ) - runtime.cxx_library( name = "op_im2row", srcs = ["op_im2row.cpp"], From 5badf544ed9687e601004f72c6276e8b3f71ccaf Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Tue, 3 Nov 2026 14:31:56 -0800 Subject: [PATCH 028/128] CI: stop the torch-free bindings check from matching load addresses (#24470) The `test-pybindings-no-aten` job builds the Python bindings without PyTorch, runs their tests, and then checks that the built library does not link against PyTorch. The check searches the output of `ldd` for `torch`, `aten` or `c10`. `ldd` also prints the address where it loaded each library, as a hex number such as `(0x00007f8ec1000000)`. The addresses change on every run, and sometimes one of them contains `c10`. The check then fails after every test has passed, although nothing links PyTorch. This has turned the job red on recent main commits. This change uses the check that `release/1.6` already has (from #24338). It matches only library names and prints the `ldd` output so a failure shows what matched. With it, the job is the same on both branches. Test plan: checked the `ldd` output of a library with 5 dependencies 4000 times, on Linux x86_64. The old pattern matched 57 times and the new check never did. The new check still flags PyTorch's `libtorch_python.so`, and reports a missing dependency when that library is copied away from the libraries it needs. --- .github/workflows/pull.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pull.yml b/.github/workflows/pull.yml index 52c009b5608..9d79d795dfe 100644 --- a/.github/workflows/pull.yml +++ b/.github/workflows/pull.yml @@ -213,7 +213,13 @@ jobs: EXECUTORCH_PYBIND_TEST_BFLOAT16_CHANNELS_LAST_PTE=cmake-out-no-aten/add_bfloat16_channels_last.pte \ PYTHONPATH=cmake-out-no-aten:.. \ python extension/pybindings/test/test_pybindings_no_aten.py - if ldd cmake-out-no-aten/_C*.so | grep -Eiq 'torch|aten|c10'; then + NO_ATEN_LINKS="$(ldd cmake-out-no-aten/_C*.so)" + printf '%s\n' "$NO_ATEN_LINKS" + if grep -q 'not found' <<<"$NO_ATEN_LINKS"; then + echo "torch-free pybindings have a missing runtime dependency" + exit 1 + fi + if grep -Eiq '^[[:space:]]*(lib(torch|aten|c10)[^[:space:]/]*\.so|[^[:space:]]*/lib(torch|aten|c10)[^[:space:]/]*\.so)' <<<"$NO_ATEN_LINKS"; then echo "torch-free pybindings unexpectedly link against ATen" exit 1 fi From 6c3242e5d5e5a5cbb76260e5d72f18de85aca2c5 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Tue, 3 Nov 2026 14:35:13 -0800 Subject: [PATCH 029/128] Mark the 3-D convolution cases that fail inside a backend as expected failures (#24459) The backend test suite now runs 3-D convolutions, since the portable kernel supports them (#24385). In the nightly flows, a few of those cases fail inside the backend that takes them, not in the portable kernel. This change marks exactly those cases as expected failures, per flow, with the reason next to each list. It also fixes one case that failed only because of the test runner build. What fails, by flow: - QNN quantized flows: lowering fails on depthwise 3-D convolutions and on replicate and circular padding, instead of leaving them to the portable kernel. With 8-bit weights, the delegated transposed `groups`, `depthwise` and `padding` cases do not match the reference. With 16-bit activations and 4-bit weights, every 3-D case fails, most of them when QNN builds the context binary. - Ethos-U85: the delegated `stride` and `groups` cases do not match. - Core ML: reflect padding fails to compile, and lowering a transposed convolution with `output_padding` raises a `TypeError`. - WebGPU: replicate padding fails to build the graph (`clamp` does not take an integer type). Three smaller changes make the lists exact and visible: - `test_conv3d_padding_modes` ran four padding modes in one test, and the first failing mode stopped the rest. Reflect, replicate and circular are now separate tests (`test_conv3d_padding` already covers zeros), so a flow lists only the mode that fails there. - The step summary of the backend test jobs counted each case by the runner's own result and never read pytest's outcome. So a listed case still showed as a failure, and one that started passing showed as an ordinary pass. The summary now counts listed failures as expected failures and lists them in their own table, apart from Failed Tests, and it lists strict unexpected passes in their own section. New unit tests cover this, including the pytest-xdist report format that CI uses. - The Ethos-U55 reflect padding case failed only because the Corstone test runner was built without `quantized_decomposed::dequantize_per_channel.out`. The runner now includes it, the same way the Arm test setup adds other kernels that suite cases leave outside the delegate. How the failures were sorted: every 3-D convolution failure in the nightly run of #24385 was read from the job logs and traced to its backend. None is in the portable kernel. The CPU, XNNPACK, Arm TOSA and VGF, Vulkan, NXP and OpenVINO flows pass every case. The QNN floating point flow stops on an unrelated error before it reaches these cases. The output mismatches are far outside the tolerance (a largest difference of 2 to 3.8 against `atol=0.1`), and the same cases failed in the nightly run on this pull request. The lists use the same strict expected-failure mechanism as the Cortex-M flow. The cases still run and still show up in the report. When a backend fixes one, the summary lists it under Unexpected Passes, and the entry can be removed. The QNN, Arm and Core ML flow helpers gain an optional `xfail_patterns` argument. No other behavior changes. Test plan: checked against the operator test reports of the nightly run of #24385 and of the nightly run on this pull request, after the split. Every pattern matches only cases that failed in that flow, and every 3-D convolution failure is matched. On this pull request, Ethos-U55 passes all 3-D cases, including reflect padding, and every listed case shows as an expected failure. The Core ML flow did not run there (no macOS runner picked it up), so its replicate and circular padding results are still unknown. The summary change was checked on the reports of that run: for example, it finds two strict unexpected passes in the NXP flow (`test_lstm_seq_lengths` and `test_lstm_num_layers`), which the old summary showed as ordinary passes. --- backends/arm/test/setup_testing.sh | 2 + backends/test/suite/flows/arm.py | 4 + backends/test/suite/flows/coreml.py | 13 ++- backends/test/suite/flows/qualcomm.py | 50 ++++++++++- backends/test/suite/flows/webgpu.py | 3 + .../suite/generate_markdown_summary_json.py | 87 +++++++++++++++---- backends/test/suite/operators/test_conv3d.py | 27 ++++-- .../suite/tests/test_markdown_summary_json.py | 81 +++++++++++++++++ 8 files changed, 239 insertions(+), 28 deletions(-) create mode 100644 backends/test/suite/tests/test_markdown_summary_json.py diff --git a/backends/arm/test/setup_testing.sh b/backends/arm/test/setup_testing.sh index 731164c6c87..eac1a6272bf 100755 --- a/backends/arm/test/setup_testing.sh +++ b/backends/arm/test/setup_testing.sh @@ -44,6 +44,7 @@ ${build_executor_runner} --pte=semihosting --target=ethos-u85-128 --system_confi # Ethos-U55/U65 # aten::avg_pool2d.out - test_adaptive_avgpool2d_batch_sizes[arm_ethos_u55] # aten::gt.Tensor_out - test_divide_f32_trunc[arm_ethos_u55] +# quantized_decomposed::dequantize_per_channel.out - test_conv3d_padding_reflect[arm_ethos_u55] # # Ethos-U85: # aten::_native_batch_norm_legit_no_training.out - test_swin_v2_t[arm_ethos_u85-static_shapes-float32] @@ -75,6 +76,7 @@ ops_list_u55=( aten::bmm.out dim_order_ops::_clone_dim_order.out dim_order_ops::_to_dim_order_copy.out + quantized_decomposed::dequantize_per_channel.out "${ops_list_quantized_decomposed[@]}" ) ops_list_u65=("${ops_list_u55[@]}") diff --git a/backends/test/suite/flows/arm.py b/backends/test/suite/flows/arm.py index 7e357f0fde9..a356bc5778b 100644 --- a/backends/test/suite/flows/arm.py +++ b/backends/test/suite/flows/arm.py @@ -25,6 +25,7 @@ def _create_arm_flow( per_channel_quantization: bool = True, use_portable_ops: bool = True, timeout: int = 1200, + xfail_patterns: list[str] | None = None, ) -> TestFlow: def _create_arm_tester(*args, **kwargs) -> ArmTester: @@ -59,6 +60,7 @@ def create_quantize_stage() -> Quantize: "TOSA/Ethos-U/VGF flows." } }, + xfail_patterns=xfail_patterns or [], ) @@ -83,6 +85,8 @@ def create_quantize_stage() -> Quantize: "arm_ethos_u85", lambda: common.get_u85_compile_spec(), quantize=True, + # Ethos-U85 takes these 3-D convolutions and its output does not match. + xfail_patterns=["test_conv3d_groups", "test_conv3d_stride"], ) ARM_VGF_FP_FLOW = _create_arm_flow( "arm_vgf_fp", diff --git a/backends/test/suite/flows/coreml.py b/backends/test/suite/flows/coreml.py index 8a532ff0003..b2cb9367380 100644 --- a/backends/test/suite/flows/coreml.py +++ b/backends/test/suite/flows/coreml.py @@ -11,6 +11,7 @@ def _create_coreml_flow( name: str, quantize: bool = False, minimum_deployment_target: Any = coremltools.target.iOS15, + xfail_patterns: list[str] | None = None, ) -> TestFlow: return TestFlow( name, @@ -20,10 +21,20 @@ def _create_coreml_flow( ), quantize=quantize, skip_patterns=["test_argmin", "test_argmax"], + xfail_patterns=xfail_patterns or [], ) -COREML_TEST_FLOW = _create_coreml_flow("coreml") +COREML_TEST_FLOW = _create_coreml_flow( + "coreml", + # Core ML fails to compile the model for the reflect padding case, and + # Core ML lowering of a transposed 3-D convolution with output_padding + # raises "'NoneType' object is not iterable". + xfail_patterns=[ + "test_conv3d_padding_reflect", + "test_convtranspose3d_output_padding", + ], +) COREML_STATIC_INT8_TEST_FLOW = _create_coreml_flow( "coreml_static_int8", quantize=True, diff --git a/backends/test/suite/flows/qualcomm.py b/backends/test/suite/flows/qualcomm.py index 99deb3d4877..1c671705c7d 100644 --- a/backends/test/suite/flows/qualcomm.py +++ b/backends/test/suite/flows/qualcomm.py @@ -12,6 +12,7 @@ def _create_qnn_flow( per_channel_linear=False, is_qat=False, use_fp16=True, + xfail_patterns: list[str] | None = None, ) -> TestFlow: if quantize and quant_dtype is None: raise RuntimeError("Quant dtype must be provided when quantize is true.") @@ -37,25 +38,66 @@ def create_quantize_stage() -> Quantize: tester_factory=create_tester, quantize=quantize, quantize_stage_factory=create_quantize_stage if quantize else None, + xfail_patterns=xfail_patterns or [], ) +# QNN fails to lower these 3-D convolutions instead of leaving them to the +# portable kernel. Partitioning asserts "DepthWise only supports Conv2d" on the +# depthwise one and raises "'NoneType' object has no attribute 'op'" on the +# replicate-padded one. For the circular-padded one, the QNN graph's inputs and +# outputs do not match the delegated program. +_QNN_CONV3D_LOWERING_XFAILS = [ + "test_conv3d_depthwise", + "test_conv3d_padding_circular", + "test_conv3d_padding_replicate", +] +# With 8-bit weights, QNN takes these transposed 3-D convolutions and its output +# does not match. +_QNN_8BIT_WEIGHT_CONV3D_XFAILS = [ + "test_convtranspose3d_depthwise", + "test_convtranspose3d_groups", + "test_convtranspose3d_padding", +] + + QNN_TEST_FLOW = _create_qnn_flow("qnn") QNN_16A16W_TEST_FLOW = _create_qnn_flow( - "qnn_16a16w", quantize=True, quant_dtype=QuantDtype.use_16a16w, use_fp16=False + "qnn_16a16w", + quantize=True, + quant_dtype=QuantDtype.use_16a16w, + use_fp16=False, + xfail_patterns=_QNN_CONV3D_LOWERING_XFAILS, ) QNN_16A8W_TEST_FLOW = _create_qnn_flow( - "qnn_16a8w", quantize=True, quant_dtype=QuantDtype.use_16a8w, use_fp16=False + "qnn_16a8w", + quantize=True, + quant_dtype=QuantDtype.use_16a8w, + use_fp16=False, + xfail_patterns=_QNN_CONV3D_LOWERING_XFAILS + _QNN_8BIT_WEIGHT_CONV3D_XFAILS, ) QNN_16A4W_TEST_FLOW = _create_qnn_flow( - "qnn_16a4w", quantize=True, quant_dtype=QuantDtype.use_16a4w, use_fp16=False + "qnn_16a4w", + quantize=True, + quant_dtype=QuantDtype.use_16a4w, + use_fp16=False, + # Every 3-D convolution fails in this flow, most of them when QNN generates + # the context binary. + xfail_patterns=["test_conv3d", "test_convtranspose3d"], ) QNN_16A4W_BLOCK_TEST_FLOW = _create_qnn_flow( "qnn_16a4w_block", quantize=True, quant_dtype=QuantDtype.use_8a8w, use_fp16=False, + # This flow still quantizes as 8a8w, not 16a4w block, so it fails the same + # cases as qnn_8a8w. Recheck the list when the dtype is corrected. + xfail_patterns=_QNN_CONV3D_LOWERING_XFAILS + _QNN_8BIT_WEIGHT_CONV3D_XFAILS, ) QNN_8A8W_TEST_FLOW = _create_qnn_flow( - "qnn_8a8w", quantize=True, quant_dtype=QuantDtype.use_8a8w, use_fp16=False + "qnn_8a8w", + quantize=True, + quant_dtype=QuantDtype.use_8a8w, + use_fp16=False, + xfail_patterns=_QNN_CONV3D_LOWERING_XFAILS + _QNN_8BIT_WEIGHT_CONV3D_XFAILS, ) diff --git a/backends/test/suite/flows/webgpu.py b/backends/test/suite/flows/webgpu.py index ee0c24f221a..3bde683f4c5 100644 --- a/backends/test/suite/flows/webgpu.py +++ b/backends/test/suite/flows/webgpu.py @@ -20,6 +20,9 @@ def _create_webgpu_flow() -> TestFlow: "lstm_batch_sizes", "upsample_nearest2d", ], + # WebGPU fails to build the graph for replicate padding ("clamp: int + # dtype unsupported"). + xfail_patterns=["test_conv3d_padding_replicate"], ) diff --git a/backends/test/suite/generate_markdown_summary_json.py b/backends/test/suite/generate_markdown_summary_json.py index 4b6edc2a635..fa1fd482992 100644 --- a/backends/test/suite/generate_markdown_summary_json.py +++ b/backends/test/suite/generate_markdown_summary_json.py @@ -13,18 +13,25 @@ class ResultCounts: total: int = 0 passes: int = 0 fails: int = 0 + expected_fails: int = 0 skips: int = 0 by_detail: dict[str, int] = field(default_factory=lambda: {}) - def add_row(self, result_value: str, result_detail: str) -> None: + def add_row( + self, result_value: str, result_detail: str, expected_fail: bool = False + ) -> None: """ - Update the result counts for the specified row. + Update the result counts for the specified row. A failure in a test that + a flow lists in xfail_patterns counts as an expected failure. """ self.total += 1 if result_value == "Pass": self.passes += 1 + elif result_value == "Fail" and expected_fail: + self.expected_fails += 1 + return elif result_value == "Fail": self.fails += 1 elif result_value == "Skip": @@ -48,6 +55,8 @@ class AggregatedSummary: counts: ResultCounts counts_by_params: dict[str, ResultCounts] failed_tests: list[list[str]] + expected_failures: list[list[str]] + unexpected_passes: list[str] # @@ -68,14 +77,27 @@ def aggregate_results(json_path: str) -> AggregatedSummary: counts = ResultCounts() failed_tests = [] counts_by_param = {} + expected_failures = [] + unexpected_passes = [] for test_data in data["tests"]: + # The subtest rows hold the runner's own result. Only pytest's outcome + # knows whether the test is in a flow's xfail_patterns. + outcome = test_data.get("outcome") + expected_fail = outcome == "xfailed" + # Under pytest-xdist the first line names the worker, so check every line. + longrepr = str((test_data.get("call") or {}).get("longrepr") or "") + if outcome == "failed" and any( + line.startswith("[XPASS(strict)]") for line in longrepr.splitlines() + ): + unexpected_passes.append(test_data["nodeid"].split("::")[-1]) + result_meta = test_data["metadata"] for subtest_meta in result_meta["subtests"]: result = subtest_meta["Result"] result_detail = subtest_meta.get("Result Detail") or "" - counts.add_row(result, result_detail) + counts.add_row(result, result_detail, expected_fail) test_id = subtest_meta["Test ID"] base_test = subtest_meta["Test Case"] @@ -84,15 +106,20 @@ def aggregate_results(json_path: str) -> AggregatedSummary: if params: if params not in counts_by_param: counts_by_param[params] = ResultCounts() - counts_by_param[params].add_row(result, result_detail) + counts_by_param[params].add_row(result, result_detail, expected_fail) if result.lower() == "fail": - failed_tests.append(subtest_meta) + if expected_fail: + expected_failures.append(subtest_meta) + else: + failed_tests.append(subtest_meta) return AggregatedSummary( counts=counts, failed_tests=failed_tests, counts_by_params=counts_by_param, + expected_failures=expected_failures, + unexpected_passes=unexpected_passes, ) @@ -120,7 +147,9 @@ def generate_markdown(json_path: str, exit_code: int = 0): # noqa (C901) # Generate Summary section print("# Summary\n") - total_excluding_skips = results.counts.passes + results.counts.fails + total_excluding_skips = ( + results.counts.passes + results.counts.fails + results.counts.expected_fails + ) pass_fraction = results.counts.passes / total_excluding_skips fail_fraction = results.counts.fails / total_excluding_skips print( @@ -129,6 +158,10 @@ def generate_markdown(json_path: str, exit_code: int = 0): # noqa (C901) print( f"- **Fail**: {results.counts.fails}/{total_excluding_skips} ({fail_fraction*100:.2f}%)" ) + expected_fail_fraction = results.counts.expected_fails / total_excluding_skips + print( + f"- **Expected Fail**: {results.counts.expected_fails}/{total_excluding_skips} ({expected_fail_fraction*100:.2f}%)" + ) print(f"- **Skip**: {results.counts.skips}") if results.counts_by_params: @@ -136,7 +169,7 @@ def generate_markdown(json_path: str, exit_code: int = 0): # noqa (C901) if len(results.counts_by_params) > 0: # Create table header - header_cols = ["Params", "Pass", "Fail", "Skip", "Pass %"] + header_cols = ["Params", "Pass", "Fail", "Expected Fail", "Skip", "Pass %"] print("| " + " | ".join(header_cols) + " |") print("|" + "|".join(["---"] * len(header_cols)) + "|") @@ -145,13 +178,16 @@ def generate_markdown(json_path: str, exit_code: int = 0): # noqa (C901) row_values = [params_str] # Add parameter values - pass_fraction = counts.passes / (counts.passes + counts.fails) + pass_fraction = counts.passes / ( + counts.passes + counts.fails + counts.expected_fails + ) # Add count values row_values.extend( [ str(counts.passes), str(counts.fails), + str(counts.expected_fails), str(counts.skips), f"{pass_fraction*100:.2f}%", ] @@ -166,6 +202,15 @@ def generate_markdown(json_path: str, exit_code: int = 0): # noqa (C901) for detail, count in sorted(results.counts.by_detail.items()): print(f"- **{detail}**: {count}/{total_rows_with_result_detail}") + if results.unexpected_passes: + print("# Unexpected Passes\n") + print( + "These tests are in a flow's xfail_patterns but passed. Remove them from the list." + ) + for test_id in results.unexpected_passes: + print(f"- {test_id}") + print() + # Generate Failed Tests section print("# Failed Tests\n") print( @@ -175,17 +220,27 @@ def generate_markdown(json_path: str, exit_code: int = 0): # noqa (C901) print('pytest -c /dev/nul backends/test/suite/ -k ""') print("```") if results.failed_tests: - header = build_header(results.failed_tests) - - escaped_header = [escape_for_markdown(col) for col in header.keys()] - print("| " + " | ".join(escaped_header) + " |") - print("|" + "|".join(["---"] * len(escaped_header)) + "|") - for rec in results.failed_tests: - row = build_row(rec, header) - print("| " + " | ".join(row) + " |") + print_table(results.failed_tests) else: print("No failed tests.\n") + if results.expected_failures: + # Shown with their details, so an expected failure that starts failing + # in a different way is still visible. + print("# Expected Failures\n") + print_table(results.expected_failures) + + +def print_table(records) -> None: + header = build_header(records) + + escaped_header = [escape_for_markdown(col) for col in header.keys()] + print("| " + " | ".join(escaped_header) + " |") + print("|" + "|".join(["---"] * len(escaped_header)) + "|") + for rec in records: + row = build_row(rec, header) + print("| " + " | ".join(row) + " |") + def build_header(data) -> dict[str, int]: """ diff --git a/backends/test/suite/operators/test_conv3d.py b/backends/test/suite/operators/test_conv3d.py index 01ffa4942df..e393934cd44 100644 --- a/backends/test/suite/operators/test_conv3d.py +++ b/backends/test/suite/operators/test_conv3d.py @@ -134,13 +134,26 @@ def test_conv3d_no_bias(self, flow: TestFlow) -> None: flow, ) - def test_conv3d_padding_modes(self, flow: TestFlow) -> None: - for mode in ["zeros", "reflect", "replicate", "circular"]: - self._test_op( - Model(padding=1, padding_mode=mode), - (torch.randn(4, 3, 8, 8, 8),), - flow, - ) + def test_conv3d_padding_reflect(self, flow: TestFlow) -> None: + self._test_op( + Model(padding=1, padding_mode="reflect"), + (torch.randn(4, 3, 8, 8, 8),), + flow, + ) + + def test_conv3d_padding_replicate(self, flow: TestFlow) -> None: + self._test_op( + Model(padding=1, padding_mode="replicate"), + (torch.randn(4, 3, 8, 8, 8),), + flow, + ) + + def test_conv3d_padding_circular(self, flow: TestFlow) -> None: + self._test_op( + Model(padding=1, padding_mode="circular"), + (torch.randn(4, 3, 8, 8, 8),), + flow, + ) def test_conv3d_channels(self, flow: TestFlow) -> None: self._test_op( diff --git a/backends/test/suite/tests/test_markdown_summary_json.py b/backends/test/suite/tests/test_markdown_summary_json.py new file mode 100644 index 00000000000..4bf509afeab --- /dev/null +++ b/backends/test/suite/tests/test_markdown_summary_json.py @@ -0,0 +1,81 @@ +import json +import os +import tempfile +import unittest + +from ..generate_markdown_summary_json import aggregate_results + + +def _test(name: str, outcome: str, results: list[str], longrepr: str = "") -> dict: + subtests = [ + { + "Test ID": f"{name}[flow1]", + "Test Case": name, + "Subtest": i, + "Flow": "flow1", + "Result": result, + "Result Detail": "Output Mismatch" if result == "Fail" else "", + } + for i, result in enumerate(results) + ] + test = { + "nodeid": f"test_file.py::{name}[flow1]", + "outcome": outcome, + "metadata": {"subtests": subtests}, + } + if longrepr: + test["call"] = {"longrepr": longrepr} + return test + + +class AggregateResultsTest(unittest.TestCase): + def _aggregate(self, tests: list[dict]): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "report.json") + with open(path, "w") as f: + json.dump({"tests": tests}, f) + return aggregate_results(path) + + def test_expected_failures_are_not_failures(self): + summary = self._aggregate( + [ + _test("test_listed", "xfailed", ["Pass", "Fail"]), + _test("test_broken", "failed", ["Fail"]), + ] + ) + self.assertEqual(summary.counts.passes, 1) + self.assertEqual(summary.counts.fails, 1) + self.assertEqual(summary.counts.expected_fails, 1) + self.assertEqual( + [r["Test ID"] for r in summary.failed_tests], ["test_broken[flow1]"] + ) + self.assertEqual( + [r["Test ID"] for r in summary.expected_failures], ["test_listed[flow1]"] + ) + self.assertEqual(summary.counts.by_detail, {"Output Mismatch": 1}) + + def test_strict_unexpected_passes_are_listed(self): + summary = self._aggregate( + [ + _test( + "test_serial", + "failed", + ["Pass"], + "[XPASS(strict)] Expected to fail by flow1 xfail_patterns", + ), + # Under pytest-xdist the first line names the worker. + _test( + "test_xdist", + "failed", + ["Pass"], + "[gw0] linux -- Python 3.11 /usr/bin/python\n" + "[XPASS(strict)] Expected to fail by flow1 xfail_patterns", + ), + _test("test_plain_pass", "passed", ["Pass"]), + ] + ) + self.assertEqual( + summary.unexpected_passes, ["test_serial[flow1]", "test_xdist[flow1]"] + ) + self.assertEqual(summary.counts.passes, 3) + self.assertEqual(summary.failed_tests, []) From df0cc0f16c291f342d75c6e91866c7f0ff627ef7 Mon Sep 17 00:00:00 2001 From: Stephen Jia Date: Tue, 3 Nov 2026 11:49:40 -0800 Subject: [PATCH 030/128] [ET-VK][benchmark] Rename migration shader targets to prototype_shaders Pull Request resolved: https://github.com/pytorch/executorch/pull/24514 ## Ulterior Motive Clean up `benchmark_v2` naming while deprecating the legacy operator benchmark (`etvk_bench`) in favor of `benchmark_v2` (`etvk_ops_bench`). ## Rationale **What**: Rename the `migration_shaders` filegroup and `migration_shaderlib` shader library in `benchmark_v2/targets.bzl` to `prototype_shaders` and `prototype_shaderlib`. **Why**: "migration" described where the shaders came from (copied from the legacy benchmark), not what they are. They are experimental `q4gsw` GEMV kernels (`q4gsw_linear_gemv__w_4x8_nc[_nosg]`) that the production runtime does not ship; only the `gemv_sg` and `gemv_nosg` routes select them. ## Details - Target names - **Before**: `:migration_shaders`, `:migration_shaderlib` - **After**: `:prototype_shaders`, `:prototype_shaderlib` - `:routes` exports `:prototype_shaderlib` in place of `:migration_shaderlib`. Nothing else references either name. - No shader sources, routes or case files change. Authored with Claude Code (AI). ghstack-source-id: 463942871 @exported-using-ghexport Differential Revision: [D129408336](https://our.internmc.facebook.com/intern/diff/D129408336/) --- backends/vulkan/test/ops/benchmark_v2/targets.bzl | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backends/vulkan/test/ops/benchmark_v2/targets.bzl b/backends/vulkan/test/ops/benchmark_v2/targets.bzl index 278873c9807..7a70bea4aec 100644 --- a/backends/vulkan/test/ops/benchmark_v2/targets.bzl +++ b/backends/vulkan/test/ops/benchmark_v2/targets.bzl @@ -6,14 +6,14 @@ def define_common_targets(is_fbcode = False): return runtime.filegroup( - name = "migration_shaders", + name = "prototype_shaders", srcs = native.glob(["operators/gemm/linear/qw/q4gsw/glsl/**/*"]), visibility = ["PUBLIC"], ) vulkan_spv_shader_lib( - name = "migration_shaderlib", - spv_filegroups = {":migration_shaders": "operators"}, + name = "prototype_shaderlib", + spv_filegroups = {":prototype_shaders": "operators"}, is_fbcode = is_fbcode, ) @@ -203,7 +203,7 @@ def define_common_targets(is_fbcode = False): "operators/convolution/q8ta/Q8taConv2dRoutes.h", ], platforms = get_platforms(), - exported_deps = [":config", ":migration_shaderlib", "//executorch/backends/vulkan:vulkan_graph_runtime"], + exported_deps = [":config", ":prototype_shaderlib", "//executorch/backends/vulkan:vulkan_graph_runtime"], # @lint-ignore BUCKLINT: Avoid `link_whole=True` link_whole = True, visibility = ["PUBLIC"], From 34115818e4f56ddb74018df93be458dd82637aab Mon Sep 17 00:00:00 2001 From: Stephen Jia Date: Tue, 3 Nov 2026 11:49:41 -0800 Subject: [PATCH 031/128] [ET-VK][benchmark] Model the q8csw weight-only fallback in references Pull Request resolved: https://github.com/pytorch/executorch/pull/24515 ## Ulterior Motive Deprecate the legacy operator benchmark (`etvk_bench`) in favor of `benchmark_v2` (`etvk_ops_bench`). GitHub CI validates the `q8csw` operators on SwiftShader, which lacks packed int8 dot products, so the `benchmark_v2` references must match the operators on such devices. ## Rationale **What**: The `q8csw` linear and conv2d references compute the weight-only result when the device lacks packed int8 dot products. A shared `int8_dot_product()` reference helper reads that capability from the reference context, and the `dq8ca_q4gsw` reference uses it in place of its inline copy. **Why**: Without int8 dot products, `linear_q8ta_q8csw` and `conv2d_q8ta_q8csw` fall back to weight-only quantization and ignore the activation quantization parameters. The references always quantized activations, so every activation-quantized case failed on such devices. The legacy benchmark skipped those cases on such devices rather than validating the fallback. ## Details - `q8csw` activation-quantized cases on devices without int8 dot products - **Before**: the reference quantizes activations; on SwiftShader, 16 of 32 `q8csw_linear` and 12 of 24 `q8csw_conv2d` cases fail. - **After**: the reference matches the weight-only fallback; all cases pass and validate the fallback. - Devices with int8 dot products: unchanged. - `int8_dot_product(spec)` reads `reference_context.int8_dot_product`, which validation already sets from the graph. It defaults to true without a reference context and throws on a non-boolean value. Authored with Claude Code (AI). ghstack-source-id: 463942877 @exported-using-ghexport Differential Revision: [D129408338](https://our.internmc.facebook.com/intern/diff/D129408338/) --- .../ops/benchmark_v2/framework/Reference.cpp | 8 ++++ .../ops/benchmark_v2/framework/Reference.h | 4 ++ .../qw/q8csw/Q8cswConv2dReference.cpp | 2 +- .../qw/dq8ca_q4gsw/Dq8caQ4gswReference.cpp | 6 +-- .../linear/qw/q8csw/Q8cswLinearReference.cpp | 2 +- .../q8ta/test/q8_benchmark_reference_test.cpp | 43 ++++++++++++++++++- 6 files changed, 56 insertions(+), 9 deletions(-) diff --git a/backends/vulkan/test/ops/benchmark_v2/framework/Reference.cpp b/backends/vulkan/test/ops/benchmark_v2/framework/Reference.cpp index beaffcab269..efe63d96613 100644 --- a/backends/vulkan/test/ops/benchmark_v2/framework/Reference.cpp +++ b/backends/vulkan/test/ops/benchmark_v2/framework/Reference.cpp @@ -57,6 +57,14 @@ tensor_arg(const Json& spec, size_t index, const TensorMap& tensors) { return tensors.at(spec.at("args").at(index).at("tensor").get()); } +bool int8_dot_product(const Json& spec) { + const Json context = spec.value("reference_context", Json::object()); + require(context.is_object(), "Reference context must be an object"); + const Json dot = context.value("int8_dot_product", Json(true)); + require(dot.is_boolean(), "Reference int8_dot_product must be boolean"); + return dot.get(); +} + } // namespace reference namespace { diff --git a/backends/vulkan/test/ops/benchmark_v2/framework/Reference.h b/backends/vulkan/test/ops/benchmark_v2/framework/Reference.h index 8aa90c4832e..a4fa5d0bd14 100644 --- a/backends/vulkan/test/ops/benchmark_v2/framework/Reference.h +++ b/backends/vulkan/test/ops/benchmark_v2/framework/Reference.h @@ -33,6 +33,10 @@ void parallel_for( const Tensor& tensor_arg(const Json& spec, size_t index, const TensorMap& tensors); void require(bool condition, const std::string& message); +// Whether the validated device supports packed int8 dot products. Operators +// with quantized activations fall back to weight-only quantization without +// them. Defaults to true when the spec has no reference context. +bool int8_dot_product(const Json& spec); } // namespace reference diff --git a/backends/vulkan/test/ops/benchmark_v2/operators/convolution/qw/q8csw/Q8cswConv2dReference.cpp b/backends/vulkan/test/ops/benchmark_v2/operators/convolution/qw/q8csw/Q8cswConv2dReference.cpp index 25321801aba..f0c4843a7ce 100644 --- a/backends/vulkan/test/ops/benchmark_v2/operators/convolution/qw/q8csw/Q8cswConv2dReference.cpp +++ b/backends/vulkan/test/ops/benchmark_v2/operators/convolution/qw/q8csw/Q8cswConv2dReference.cpp @@ -35,7 +35,7 @@ static Tensor q8csw_conv2d_reference( return oc * weight.sizes[1] + (ky * p.kernel[1] + kx) * g.in_channels_per_group + ci; }; - if (quantized) { + if (quantized && int8_dot_product(spec)) { const float scale = q8ta::scale(spec, 1); const int64_t zp = q8ta::integer(spec, 2); input = q8ta::quantized_input(input, scale, zp); diff --git a/backends/vulkan/test/ops/benchmark_v2/operators/gemm/linear/qw/dq8ca_q4gsw/Dq8caQ4gswReference.cpp b/backends/vulkan/test/ops/benchmark_v2/operators/gemm/linear/qw/dq8ca_q4gsw/Dq8caQ4gswReference.cpp index 168db9c689c..26b7687d560 100644 --- a/backends/vulkan/test/ops/benchmark_v2/operators/gemm/linear/qw/dq8ca_q4gsw/Dq8caQ4gswReference.cpp +++ b/backends/vulkan/test/ops/benchmark_v2/operators/gemm/linear/qw/dq8ca_q4gsw/Dq8caQ4gswReference.cpp @@ -23,11 +23,7 @@ Tensor dq8ca_q4gsw_reference( spec.at("args").at(6), spec.at("args").at(7), spec.at("args").at(8)}; - const Json context = spec.value("reference_context", Json::object()); - require(context.is_object(), "Reference context must be an object"); - const Json dot = context.value("int8_dot_product", Json(true)); - require(dot.is_boolean(), "DQ8CA reference capability must be boolean"); - if (!dot.get()) { + if (!int8_dot_product(spec)) { return q4gsw_linear_reference(canonical, tensors, threads); } TensorMap converted = tensors; diff --git a/backends/vulkan/test/ops/benchmark_v2/operators/gemm/linear/qw/q8csw/Q8cswLinearReference.cpp b/backends/vulkan/test/ops/benchmark_v2/operators/gemm/linear/qw/q8csw/Q8cswLinearReference.cpp index e0a9243a883..0562d7b4b51 100644 --- a/backends/vulkan/test/ops/benchmark_v2/operators/gemm/linear/qw/q8csw/Q8cswLinearReference.cpp +++ b/backends/vulkan/test/ops/benchmark_v2/operators/gemm/linear/qw/q8csw/Q8cswLinearReference.cpp @@ -24,7 +24,7 @@ static Tensor q8csw_linear_reference( const Tensor* bias = spec.at("args").at(bi).is_null() ? nullptr : &tensor_arg(spec, bi, tensors); - if (quantized) { + if (quantized && int8_dot_product(spec)) { const float scale = q8ta::scale(spec, 1); const int64_t zp = q8ta::integer(spec, 2); require( diff --git a/backends/vulkan/test/ops/benchmark_v2/operators/quantize/q8ta/test/q8_benchmark_reference_test.cpp b/backends/vulkan/test/ops/benchmark_v2/operators/quantize/q8ta/test/q8_benchmark_reference_test.cpp index bdab0110e8b..21550be7f66 100644 --- a/backends/vulkan/test/ops/benchmark_v2/operators/quantize/q8ta/test/q8_benchmark_reference_test.cpp +++ b/backends/vulkan/test/ops/benchmark_v2/operators/quantize/q8ta/test/q8_benchmark_reference_test.cpp @@ -30,10 +30,12 @@ Json tensor_arg( : "constant"}, {"data", data}}}}; } -std::vector compute(const char* op, const Json& args) { - const Json spec = +std::vector +compute(const char* op, const Json& args, bool int8_dot_product = true) { + Json spec = parse_configs({{"operator", op}, {"args_sweep", Json::array({args})}}) .front(); + spec["reference_context"] = {{"int8_dot_product", int8_dot_product}}; auto data = make_tensor_data(spec.at("tensors")); data.erase("output"); return reference_outputs(spec, {}, data, 2).at("output"); @@ -329,5 +331,42 @@ TEST( compute("et_vk.linear_q8ta_q8csw.default", args), (std::vector{.5})); } +TEST( + Q8BenchmarkReference, + ActivationQuantizedConvolutionUsesWeightOnlyFallbackWithoutIntegerDot) { + const Json args = Json::array( + {tensor_arg("input", {1, 1, 1, 1}, {.5}), + {{"float", .07f}}, + {{"int", -3}}, + tensor_arg("weight", {1, 4}, {64, 0, 0, 0}, "int8"), + tensor_arg("sums", {1}, {64}, "int32"), + tensor_arg("scales", {1}, {.25}), + tensor_arg("bias", {1}, {.5}), + {{"int_list", {1, 1}}}, + {{"int_list", {1, 1}}}, + {{"int_list", {0, 0}}}, + {{"int_list", {1, 1}}}, + {{"int", 1}}, + tensor_arg("output", {1, 1, 1, 1}, {{"fill", 0}})}); + EXPECT_EQ( + compute("et_vk.conv2d_q8ta_q8csw.default", args, false), + (std::vector{8.5})); +} +TEST( + Q8BenchmarkReference, + ActivationQuantizedLinearUsesWeightOnlyFallbackWithoutIntegerDot) { + const Json args = Json::array( + {tensor_arg("input", {1, 1}, {.5}), + {{"float", .07f}}, + {{"int", -3}}, + tensor_arg("weight", {1, 1}, {64}, "int8"), + tensor_arg("sums", {1}, {64}, "int32"), + tensor_arg("scales", {1}, {.25}), + tensor_arg("bias", {1}, {.5}), + tensor_arg("output", {1, 1}, {{"fill", 0}})}); + EXPECT_EQ( + compute("et_vk.linear_q8ta_q8csw.default", args, false), + (std::vector{8.5})); +} } // namespace } // namespace vkcompute::shader_profiling From 5cc1f129be2fab48a30d034aba5bf9209d1bdddd Mon Sep 17 00:00:00 2001 From: Stephen Jia Date: Tue, 3 Nov 2026 11:49:41 -0800 Subject: [PATCH 032/128] [ET-VK][benchmark] Print shader bindings as a string in JSON summaries Pull Request resolved: https://github.com/pytorch/executorch/pull/24516 ## Ulterior Motive Keep `etvk_ops_bench` compact JSON summaries short and consistent between raw shader and operator cases. ## Rationale **What**: In the default `--output_format=json`, raw shader cases print `bindings_str` instead of the full `bindings` object. **Why**: Operator cases already condense `args` into `args_str`, but shader cases printed every binding with its type, sizes, dtype, layout, access, and binding index, which dominated the summary. ## Details - Shader inputs in `json` summaries - **Before**: `bindings` was printed as a nested object, one entry per binding. - **After**: `bindings_str` holds the table's `Inputs` string: read and read-write tensor bindings in binding order, followed by uniform and push constant values, without width truncation. For `examples/texture_shader.json`: `t3[1,1,1,8]f32.width, t3[1,1,1,8]f32.width, [2]`. `--output_format=json_full` still prints `bindings` in full. Authored with Claude Code (AI). ghstack-source-id: 463942870 @exported-using-ghexport Differential Revision: [D129107392](https://our.internmc.facebook.com/intern/diff/D129107392/) --- backends/vulkan/test/ops/benchmark_v2/README.md | 7 +++++-- .../vulkan/test/ops/benchmark_v2/framework/Report.cpp | 2 ++ .../ops/benchmark_v2/framework/test/report_test.cpp | 11 ++++++++--- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/backends/vulkan/test/ops/benchmark_v2/README.md b/backends/vulkan/test/ops/benchmark_v2/README.md index b745d451c54..31141fbd5c8 100644 --- a/backends/vulkan/test/ops/benchmark_v2/README.md +++ b/backends/vulkan/test/ops/benchmark_v2/README.md @@ -504,6 +504,9 @@ full JSON, with these substitutions: - Operator `args` becomes `args_str`, using the full `Inputs` notation, including non-tensor arguments, storage, dtype, and non-constant layouts. Output-only tensors are excluded. Table width limits do not truncate this string. +- Shader `bindings` becomes `bindings_str`, using the same `Inputs` notation: + read and read-write tensor bindings in binding order, followed by uniform and + push constant values. Table width limits do not truncate this string. - `gpu_timestamp_operator_us`, `gpu_timestamp_batch_us`, and wall timing arrays become objects with `min`, `median`, and `max`. Median is the middle sorted sample, averaging the two middle samples for even counts. Empty timing arrays @@ -512,8 +515,8 @@ full JSON, with these substitutions: pass's start/end readings. Unavailable readings are ignored; a pass without usable readings becomes `null`. Zero remains a valid observation. -Shader timestamp statistics, shader breakdowns, raw shader bindings, validation, -and other metadata retain their full JSON representation. +Shader timestamp statistics, shader breakdowns, validation, and other metadata +retain their full JSON representation. ```json { diff --git a/backends/vulkan/test/ops/benchmark_v2/framework/Report.cpp b/backends/vulkan/test/ops/benchmark_v2/framework/Report.cpp index 58fbdb75317..f010c728767 100644 --- a/backends/vulkan/test/ops/benchmark_v2/framework/Report.cpp +++ b/backends/vulkan/test/ops/benchmark_v2/framework/Report.cpp @@ -355,6 +355,8 @@ std::string format_condensed_report(const Report& report) { key == "wall_time_batch_us" || key == "wall_time_shader_us"; if (key == "args") { condensed["args_str"] = table_text(table_inputs(original, true)); + } else if (key == "bindings") { + condensed["bindings_str"] = table_text(table_inputs(original, false)); } else if (batch_timing && value.is_array()) { const Report stats = latency_statistics(value.get>()); diff --git a/backends/vulkan/test/ops/benchmark_v2/framework/test/report_test.cpp b/backends/vulkan/test/ops/benchmark_v2/framework/test/report_test.cpp index 7550d0f650f..40694334f1b 100644 --- a/backends/vulkan/test/ops/benchmark_v2/framework/test/report_test.cpp +++ b/backends/vulkan/test/ops/benchmark_v2/framework/test/report_test.cpp @@ -244,7 +244,8 @@ TEST(BenchmarkReport, ShaderInputFloatsUseThreeSignificantFigures) { .find("[1.23,1234567], [-0.0123,10]"), std::string::npos); const Report compact = Report::parse(format_condensed_report(report)); - EXPECT_EQ(compact.at("bindings"), report.at("bindings")); + EXPECT_FALSE(compact.contains("bindings")); + EXPECT_EQ(compact.at("bindings_str"), "[1.23,1234567], [-0.0123,10]"); EXPECT_EQ(compact.at("push_constants"), report.at("push_constants")); } @@ -839,13 +840,17 @@ TEST(BenchmarkReport, CondensedJsonSummarizesPassClockRanges) { TEST(BenchmarkReport, CondensedJsonPreservesShaderStatisticsAndCaseGroups) { Report report = { {"shader_name", "copy"}, - {"bindings", {{"input", {{"sizes", {8}}, {"dtype", "float32"}}}}}}; + {"bindings", + {{"output", {{"sizes", {8}}, {"dtype", "float32"}, {"access", "write"}}}, + {"input", {{"sizes", {8}}, {"dtype", "float32"}}}}}, + {"push_constants", {{{"dtype", "float32"}, {"data", {0.5, 2}}}}}}; report.update(measurement_report(10, false, {10, 20, 30}, {30, 10, 20})); const Report error = error_report({{"operator", "bad"}}, "test error"); const Report condensed = Report::parse(format_condensed_report(Report::array({report, error}))); ASSERT_EQ(condensed.size(), 2); - EXPECT_EQ(condensed[0].at("bindings"), report.at("bindings")); + EXPECT_FALSE(condensed[0].contains("bindings")); + EXPECT_EQ(condensed[0].at("bindings_str"), "b[8]f32.width, [0.5,2]"); EXPECT_EQ( condensed[0].at("gpu_timestamp_shader_us"), report.at("gpu_timestamp_shader_us")); From f9dcc9f9043ca4c0e442fde962f47b845bb359b1 Mon Sep 17 00:00:00 2001 From: Stephen Jia Date: Tue, 3 Nov 2026 11:49:42 -0800 Subject: [PATCH 033/128] [ET-VK][benchmark] Add sustained replay for hardware counter capture Pull Request resolved: https://github.com/pytorch/executorch/pull/24517 ## Ulterior Motive Make `etvk_ops_bench` (benchmark_v2) the tool for measuring GPU hardware counters on a specific compute shader dispatch, replacing one-off single-shader replay binaries written per investigation. ## Rationale **What**: Add an optional case field, `sustain_s`. When set, the benchmark replays the calibrated query-free batch back-to-back for that many seconds and prints the window boundaries to stderr. Add `examples/conv2d_gemm_sustained.json`, a sustained raw shader case for the GEMM step of an im2col convolution. **Why**: Vendor counter tools (Snapdragon Profiler CLI, libGPUCounters, Perfetto `gpu.counters`) sample the whole GPU over periods of 100 ms or more. Attributing their samples to one shader requires a multi-second run of only that shader and a way to align sampler output with it. The existing measurement passes last about half a second each and alternate wall-time, timestamp, and profiling submissions. ## Details With `sustain_s` set, calibration and warmup run as before. The benchmark then resubmits the calibrated batch until `sustain_s` seconds elapse. This window replaces the wall-time, GPU timestamp, and shader profiling passes. The window boundaries are printed to stderr as they occur, in `CLOCK_MONOTONIC` nanoseconds: ``` sustain_begin monotonic_ns=156312739333286 case= sustain_end monotonic_ns=156324742238646 case= ``` Reports add `sustain_s` and `sustain_monotonic_ns` (`begin`, `end`). `benchmark_runs` counts the batches replayed in the window, so `benchmark_runs` times `dispatches_per_batch` is the dispatch count for normalizing counter totals. Without `sustain_s`, behavior and report contents are unchanged. Operator cases replay their whole dispatch chain. To profile one dispatch of an operator, write a raw shader case that reproduces it. `examples/conv2d_gemm_sustained.json` reproduces one `conv2d_gemm_texture2d_half` dispatch of an FP16 im2col convolution (input `[1, 64, 128, 128]`, weight `[128, 64, 3, 3]`, one 113-row output tile); the README explains how its bindings and push constants derive from the convolution shape. Typical capture: start the benchmark, wait for `sustain_begin`, then sample for less than `sustain_s`: ```sh ./etvk_ops_bench --config=case.json --validation_mode=none 2> bench.err & until grep -q sustain_begin bench.err; do sleep 0.05; done ./sdpcli r c -d 10000 -p 200 -o out wait ``` Samplers that timestamp their samples (libGPUCounters, Perfetto with a `CLOCK_MONOTONIC` trace clock) can instead keep samples between the two markers. Authored with Claude Code (AI). ghstack-source-id: 463942887 @exported-using-ghexport Differential Revision: [D129107391](https://our.internmc.facebook.com/intern/diff/D129107391/) --- .../vulkan/test/ops/benchmark_v2/README.md | 40 +++++++++++ .../examples/conv2d_gemm_sustained.json | 71 +++++++++++++++++++ .../ops/benchmark_v2/framework/Config.cpp | 7 ++ .../ops/benchmark_v2/framework/Timing.cpp | 47 +++++++++--- .../framework/test/config_test.cpp | 11 +++ .../framework/test/validation_test.cpp | 17 +++++ 6 files changed, 185 insertions(+), 8 deletions(-) create mode 100644 backends/vulkan/test/ops/benchmark_v2/examples/conv2d_gemm_sustained.json diff --git a/backends/vulkan/test/ops/benchmark_v2/README.md b/backends/vulkan/test/ops/benchmark_v2/README.md index 31141fbd5c8..7aeb46e45f1 100644 --- a/backends/vulkan/test/ops/benchmark_v2/README.md +++ b/backends/vulkan/test/ops/benchmark_v2/README.md @@ -92,6 +92,7 @@ Benchmark conditions such as `target_ms` and `benchmark_runs` belong in the JSON | `examples/q8ta_conv2d.json` | Quantized int8 activations, per-channel int8 weights, and int8 output. | | `examples/linear.json` | FP32/FP16 linear projections and feed-forward expansion, with decode and batched/prefill shapes. | | `examples/q4gsw_linear.json` | Weight-only Q4GSW linear with FP32 and FP16 activations. | +| `examples/conv2d_gemm_sustained.json` | 12-second sustained replay of the FP16 im2col convolution GEMM shader, for hardware counter sampling. Run with `--validation_mode=none`. | | Field | Meaning | | --- | --- | @@ -114,6 +115,7 @@ Benchmark conditions such as `target_ms` and `benchmark_runs` belong in the JSON | `max_dispatches` | Invocation count cap, default 200 for operators and 65536 for shaders. | | `warmup_runs` | Unmeasured batches per timing mode, default 3. | | `benchmark_runs` | Measured batches per timing mode, default 10. | +| `sustain_s` | Replace the measurement passes with back-to-back query-free replay for this many seconds. See [sustained replay](#sustained-replay-for-hardware-counters). | | `check` | Registered validation-only check, e.g. capability or dynamic-resize checks. | | `reference` | Optional registered CPU reference name; inferred for supported operators/shaders. | | `validation_method` | Comparison function and tolerances, e.g. `{"function":"is_close","atol":0.01,"rtol":0.01}`. Optional `outputs` selects named tensors to check. | @@ -563,6 +565,7 @@ Pass selection depends on case type and timestamp support: | Raw shader | Yes | Wall time, whole-batch GPU timestamps. | | Operator | Yes | Wall time, whole-batch GPU timestamps, per-dispatch GPU timestamps. | | Raw shader or operator | No | Wall time only. GPU batch/operator timings are `null` in `json` and empty arrays in `json_full`; raw shader statistics are `null`, and shader breakdowns are omitted. | +| Raw shader or operator with `sustain_s` | Either | [Sustained replay](#sustained-replay-for-hardware-counters) window only. GPU timestamp fields are `null` or empty as above, and shader breakdowns are omitted. | Each enabled pass performs `warmup_runs` unmeasured replays (default 3), followed by `benchmark_runs` measured replays (default 10; many examples specify 5). @@ -715,6 +718,43 @@ Prepacking remains outside timing. Breakdowns are omitted when timestamps are unavailable, including on this repository's SwiftShader build. +### Sustained replay for hardware counters + +GPU hardware counters are sampled system-wide, so attributing them to one shader +requires a workload that runs only that shader across several sample periods. +With `sustain_s`, the benchmark calibrates and warms up as usual, then resubmits +the query-free batch back-to-back until `sustain_s` seconds elapse. This window +replaces the wall time, GPU timestamp, and shader profiling passes. Operator +cases replay their full dispatch chain, so profile one shader with a raw shader +case that reproduces its dispatch. + +`examples/conv2d_gemm_sustained.json` reproduces one dispatch of +`conv2d_gemm_texture2d_half`, the GEMM step of an FP16 im2col convolution with +input `[1, 64, 128, 128]` and weight `[128, 64, 3, 3]`. Each dispatch covers a +113-row output tile, so K = 3 x 3 x 64 = 576, M = 113 x 128 = 14464, and +N = 128. The bindings follow the shader interface: the output, the `[M, K]` +im2col tile, the `[K/4, N/4 x 16]` packed weight, the bias, and the output sizes +UBO. The push constants are `(K/4, row offset, rows per tile, 0)` followed by +unused clamp bounds. This shader has no registered CPU reference, so run the +example with `--validation_mode=none`. + +The window boundaries are printed to stderr as they occur: + +```text +sustain_begin monotonic_ns=156312739333286 case=conv2d_gemm tile0 fp16 M14464 K576 N128 +sustain_end monotonic_ns=156324742238646 case=conv2d_gemm tile0 fp16 M14464 K576 N128 +``` + +`monotonic_ns` is `CLOCK_MONOTONIC` on Linux and Android. Start a sampler when +`sustain_begin` appears and stop it before `sustain_s` elapses, or keep only +samples timestamped between the two markers. Reports add `sustain_s` and +`sustain_monotonic_ns` with `begin` and `end`. `benchmark_runs` counts the +batches replayed in the window, so the window contains `benchmark_runs` times +the repetition count dispatches. `wall_time_batch_us` and the Android wall-time +clock samples cover those batches; GPU timestamp fields are empty and shader +breakdowns are omitted. Validation, prepacking, and calibration finish before +the window begins. + ## Throughput Enable either or both metrics in a case definition: diff --git a/backends/vulkan/test/ops/benchmark_v2/examples/conv2d_gemm_sustained.json b/backends/vulkan/test/ops/benchmark_v2/examples/conv2d_gemm_sustained.json new file mode 100644 index 00000000000..03412665188 --- /dev/null +++ b/backends/vulkan/test/ops/benchmark_v2/examples/conv2d_gemm_sustained.json @@ -0,0 +1,71 @@ +{ + "name": "conv2d_gemm tile0 fp16 M14464 K576 N128", + "shader": "conv2d_gemm_texture2d_half", + "gwg_size": [32, 3616, 1], + "lwg_size": [8, 8, 1], + "sustain_s": 12, + "bindings": [ + { + "texture3d": { + "name": "out", + "sizes": [1, 128, 128, 128], + "dtype": "float16", + "layout": "channels", + "access": "write", + "data": { + "fill": 0 + } + } + }, + { + "texture2d": { + "name": "im2col", + "sizes": [14464, 576], + "dtype": "float16", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "texture2d": { + "name": "weight_packed", + "sizes": [144, 512], + "dtype": "float16", + "data": { + "rand_uniform_float": [-0.05, 0.05] + } + } + }, + { + "texture2d": { + "name": "bias", + "sizes": [128], + "dtype": "float16", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "meta_ubo": "out", + "field": "sizes" + } + ], + "specialization_constants": [ + { + "dtype": "int32", + "value": 0 + } + ], + "push_constants": [ + { + "dtype": "int32", + "data": [144, 0, 113, 0] + }, + { + "dtype": "float32", + "data": [0, 0, 0, 0] + } + ] +} diff --git a/backends/vulkan/test/ops/benchmark_v2/framework/Config.cpp b/backends/vulkan/test/ops/benchmark_v2/framework/Config.cpp index f967bd50793..c02471b08a6 100644 --- a/backends/vulkan/test/ops/benchmark_v2/framework/Config.cpp +++ b/backends/vulkan/test/ops/benchmark_v2/framework/Config.cpp @@ -407,6 +407,13 @@ void validate_config(const Json& spec) { spec.at("target_ms").get() <= 10000, "target_ms must be in (0, 10000]"); } + if (spec.contains("sustain_s")) { + require( + spec.at("sustain_s").is_number() && + spec.at("sustain_s").get() > 0 && + spec.at("sustain_s").get() <= 3600, + "sustain_s must be in (0, 3600]"); + } } } // namespace diff --git a/backends/vulkan/test/ops/benchmark_v2/framework/Timing.cpp b/backends/vulkan/test/ops/benchmark_v2/framework/Timing.cpp index 9e976a0b678..476270e9073 100644 --- a/backends/vulkan/test/ops/benchmark_v2/framework/Timing.cpp +++ b/backends/vulkan/test/ops/benchmark_v2/framework/Timing.cpp @@ -13,6 +13,8 @@ #include #include #include +#include +#include #include #include @@ -27,6 +29,12 @@ Report read_gpu_clock(GpuClock* clock) { return frequency ? Report(*frequency) : Report(); } +int64_t monotonic_ns(Clock::time_point time) { + return std::chrono::duration_cast( + time.time_since_epoch()) + .count(); +} + Report profile_operator_shaders( const Json& spec, ComputeGraph& graph, @@ -208,16 +216,35 @@ Report measure( } const uint32_t warmup = spec.value("warmup_runs", 3u); const uint32_t runs = spec.value("benchmark_runs", 10u); + const double sustain_s = spec.value("sustain_s", 0.0); encode(count, false); std::vector wall_samples; std::vector gpu_samples; for (uint32_t i = 0; i < warmup; ++i) { replay(); } - for (uint32_t i = 0; i < runs; ++i) { - wall_samples.push_back(measured_replay(wall_clock_samples)); + Report sustain_window; + if (sustain_s > 0) { + // External hardware counter samplers align their captures to these lines. + const std::string name = + spec.value("name", spec.value("operator", spec.value("shader", ""))); + const auto begin = Clock::now(); + std::cerr << "sustain_begin monotonic_ns=" << monotonic_ns(begin) + << " case=" << name << std::endl; + do { + wall_samples.push_back(measured_replay(wall_clock_samples)); + } while (Clock::now() - begin < std::chrono::duration(sustain_s)); + const auto end = Clock::now(); + std::cerr << "sustain_end monotonic_ns=" << monotonic_ns(end) + << " case=" << name << std::endl; + sustain_window = { + {"begin", monotonic_ns(begin)}, {"end", monotonic_ns(end)}}; + } else { + for (uint32_t i = 0; i < runs; ++i) { + wall_samples.push_back(measured_replay(wall_clock_samples)); + } } - if (timestamps.available()) { + if (timestamps.available() && sustain_s == 0) { encode(count, true); for (uint32_t i = 0; i < warmup; ++i) { replay(); @@ -235,10 +262,14 @@ Report measure( : "dispatches_per_batch", count}, {"warmup_runs", warmup}, - {"benchmark_runs", runs}, - {"gpu_timestamps_available", timestamps.available()}, - {"timestamp_period_ns", timestamps.period_ns()}, - {"timestamp_valid_bits", timestamps.valid_bits()}}; + {"benchmark_runs", wall_samples.size()}}; + if (sustain_s > 0) { + result["sustain_s"] = sustain_s; + result["sustain_monotonic_ns"] = std::move(sustain_window); + } + result["gpu_timestamps_available"] = timestamps.available(); + result["timestamp_period_ns"] = timestamps.period_ns(); + result["timestamp_valid_bits"] = timestamps.valid_bits(); const Report measurements = measurement_report( count, spec.contains("operator"), gpu_samples, wall_samples); for (const auto& measurement : measurements.items()) { @@ -257,7 +288,7 @@ Report measure( if (!throughput.empty()) { result["throughput"] = std::move(throughput); } - if (spec.contains("operator") && timestamps.available()) { + if (spec.contains("operator") && timestamps.available() && sustain_s == 0) { result.update(profile_operator_shaders( spec, graph, diff --git a/backends/vulkan/test/ops/benchmark_v2/framework/test/config_test.cpp b/backends/vulkan/test/ops/benchmark_v2/framework/test/config_test.cpp index 0568a50abeb..6e3e5a07ddd 100644 --- a/backends/vulkan/test/ops/benchmark_v2/framework/test/config_test.cpp +++ b/backends/vulkan/test/ops/benchmark_v2/framework/test/config_test.cpp @@ -177,6 +177,17 @@ TEST(ShaderProfilingConfig, RejectsUnknownTensorAndInvalidOptions) { EXPECT_THROW(parse_configs(spec), std::invalid_argument); } +TEST(ShaderProfilingConfig, ValidatesSustainDuration) { + for (Json spec : {config(), operator_config()}) { + spec["sustain_s"] = 2.5; + EXPECT_EQ(parse_configs(spec)[0].at("sustain_s"), 2.5); + for (const Json& invalid : {Json(0), Json(-1), Json(3601), Json("1")}) { + spec["sustain_s"] = invalid; + EXPECT_THROW(parse_configs(spec), std::invalid_argument); + } + } +} + TEST(ShaderProfilingConfig, PacksTypedInputsAndRejectsTruncation) { auto bytes = make_data({{"dtype", "int32"}, {"data", {-7, 42}}}, 2); int32_t values[2]; diff --git a/backends/vulkan/test/ops/benchmark_v2/framework/test/validation_test.cpp b/backends/vulkan/test/ops/benchmark_v2/framework/test/validation_test.cpp index 43f5d064ec5..307dec41e67 100644 --- a/backends/vulkan/test/ops/benchmark_v2/framework/test/validation_test.cpp +++ b/backends/vulkan/test/ops/benchmark_v2/framework/test/validation_test.cpp @@ -224,6 +224,23 @@ TEST_F(BenchmarkValidation, BenchmarkAddsRegisteredWorkAndWallThroughput) { } } +TEST_F(BenchmarkValidation, SustainedReplaySkipsTimestampPass) { + Json spec = copy_case({8, 1, 1}); + spec.update( + {{"sustain_s", 0.05}, + {"target_ms", 0.01}, + {"warmup_runs", 0}, + {"max_dispatches", 2}}); + const Report result = benchmark_shader(spec, prepare_tensor_data(spec)); + const auto& window = result.at("sustain_monotonic_ns"); + EXPECT_GE( + window.at("end").get() - window.at("begin").get(), + 50000000); + EXPECT_EQ( + result.at("benchmark_runs"), result.at("wall_time_batch_us").size()); + EXPECT_TRUE(result.at("gpu_timestamp_batch_us").empty()); +} + TEST_F(BenchmarkValidation, UnsupportedShaderIsRejectedBeforePipelineCreation) { ComputeGraph graph{GraphConfig{}}; if (graph.context()->adapter_ptr()->supports_int8_dot_product()) { From 2bb584374f7d4334cc3805e3e1fb02efe04e9bc1 Mon Sep 17 00:00:00 2001 From: Stephen Jia Date: Tue, 3 Nov 2026 11:49:42 -0800 Subject: [PATCH 034/128] [ET-VK][ci] Run benchmark_v2 CI cases instead of etvk_bench in Skycastle Pull Request resolved: https://github.com/pytorch/executorch/pull/24518 ## Ulterior Motive Deprecate the legacy operator benchmark (`test/ops/benchmark`, `etvk_bench`) in favor of `benchmark_v2` (`etvk_ops_bench`). As a first step, this diff moves the Skycastle operator correctness tests to `benchmark_v2`. ## Rationale **What**: `etvk-eureka-tests` and `etvk-gpu-host-tests` now run `etvk_ops_bench --validation_only` on each operator directory's `cases/correctness.json`, replacing every `etvk_bench` step. Each CI directory's `correctness.json` now holds every case CI validates for it: `pw_correctness.json` and `dw_correctness.json` merge into it, and it gains the performance-set cases that cover shaders no correctness case reaches. Four directories that CI never validated also get steps. **Why**: CI exists to check correctness. The legacy steps also ran performance sets, with calibration and timing, and many of their large shapes skipped validation. Copying those sets into `benchmark_v2` CI would keep the cost without adding coverage. A single correctness collection per directory makes the CI input explicit. ## Details - CI steps - **Before**: 19 `etvk_bench --op=... --set=...` steps on Android and 17 on the GPU host. Each step ran correctness and performance sets with timing, and failed when its output contained `FAILED`. - **After**: 18 steps on Android and 16 on the GPU host, one per operator directory. Each step runs `--validation_only` and fails when `etvk_ops_bench` exits non-zero. - The separate `pw`/`dw` steps for `q8ta_conv2d`, `conv2d` and `conv1d` merge into their operator's step. - New steps: `binary/add`, `unary/q8ta`, `pixel_shuffle/q8ta` and `embedding/qw/q4gsw`. - The GPU host still skips `q8csw_conv2d` and `mm`, as before. - Test binary staging - **Before**: every step built the test binary (an uncached build action each) and pushed it to the device or GPU host, then deleted it. - **After**: each workflow builds `etvk_ops_bench` once and pushes it once, before the first operator step. Steps push only their `correctness.json`, and a final step removes the binary after the last operator step. - Case collections - **Before**: `conv2d`, `conv1d` and `q8ta_conv2d` split correctness cases across `correctness.json`, `pw_correctness.json` and `dw_correctness.json`. CI ran a mix of correctness and performance sets. - **After**: each CI directory's `correctness.json` is the collection CI runs. - The 6 `pw_correctness.json` and `dw_correctness.json` files are removed. Their cases moved into `correctness.json` unchanged. - 6 `correctness.json` files gain cases (`sdpa`, `conv2d`, `q8ta_conv2d`, `q4gsw_linear`, `mm`, `embedding_q4gsw`), and `conv1d` gets a new one. The other 11 are unchanged. - Known-bug cases move to collections named after the bug (below). - `correctness.json` contents - The directory's existing correctness cases, plus the other correctness-oriented sets the legacy step ran, copied unchanged. For `q8ta_conv2d` this includes `im2col`, `narrow_workgroup`, `streaming_im2col`, `dw_narrow_workgroup` and the two check cases. - The cheapest performance-set case for each shader that only performance sets dispatch, on Adreno 830, Mali-G715 or SwiftShader. This adds 47 cases: - 20 for `mm`: no correctness case uses FP16 or a 2/4-row output tile. - 12 for `conv2d`. - 8 for `conv1d`: FP16. - 4 for `q4gsw_linear`: `gemv_coop`. - 2 for `sdpa`: FP16 decode. `sdpa`'s `decode` set times Llama decode shapes, so it counts as a performance set. - 1 for `embedding_q4gsw`: FP32 linear-weight buffer. - Definitions keep their source names, so a failure maps back to the source set. - Known-bug collections, not run in CI. Both bugs reproduce in legacy `etvk_bench`. - `convolution/q8ta/cases/legacy_4w4c_padding.json`: the `legacy_4w4c` route of `q8ta/conv2d/correctness ... (10)` and `(23)`, and `q8ta/conv2d/dw_correctness [18]`. - These fail when the width is not a multiple of 4 and the input zero point is nonzero. - The shader reads the padding lanes of the last 4w4c block, which `benchmark_v2` fills with 0. - The legacy harness quantized a float input, which writes the zero point there, so its equivalent cases pass. - `embedding/qw/q4gsw/cases/texture3d_multirow_indices.json`: the texture3d sweeps of `embedding_q4gsw correctness (6)`, `(8)` and `(10)`. - With texture storage, the shader fetches indices as if they all sat in one texel row (`ivec3(indices_idx >> 2, 0, 0)`). - Indices tensors that span more than one row (`[2,3]`, `[2,4]`, `[4,8]`) therefore read padding lanes or out-of-bounds texels. - Single-row indices, such as LLM tokens `[1, S]`, are correct. - Other exclusion: `q8ta/conv2d/im2col (2)` with `im2col_auto` stays in `im2col.json` but not in `correctness.json`. It fails on all tested devices with "Signed q8ta pointwise convolution requires texture weights", and legacy CI did not run it. - FP16 tolerances: legacy CI only timed five FP16 performance shapes with long reductions (K up to 4096), so their tolerances were never validated, and the source tolerances fail or nearly fail. Their `atol` is 3x the largest error observed on S25, Pixel 8 Pro and SwiftShader. | CI step | Directory | Legacy CI sets (expanded cases) | New `correctness.json` cases | Added perf cases | | --- | --- | --- | --- | --- | | `test_q8csw_linear` | `gemm/linear/qw/q8csw` | correctness, performance (44) | 32 | 0 | | `test_q8csw_conv2d` | `convolution/qw/q8csw` | correctness, performance (30) | 24 | 0 | | `test_q4gsw_linear` | `gemm/linear/qw/q4gsw` | correctness, performance (144) | 132 | 4 | | `test_choose_qparams_per_row` | `quantization/choose_qparams_per_row` | correctness, performance (22) | 20 | 0 | | `test_sdpa` | `attention/sdpa` | correctness, performance, decode (54) | 35 | 2 | | `test_q8ta_qdq` | `quantize/q8ta` | correctness (1136) | 1136 | 0 | | `test_q8ta_clone` | `clone/q8ta` | correctness (850) | 850 | 0 | | `test_q8ta_binary` | `binary/q8ta` | correctness (330) | 330 | 0 | | `test_q8ta_conv2d` | `convolution/q8ta` | 12 sets (460) | 265 | 0 | | `test_q8ta_conv2d_transposed` | `convolution/q8ta/transposed` | correctness, performance (24) | 15 | 0 | | `test_q8ta_linear` | `gemm/linear/q8ta` | correctness, performance (25) | 16 | 0 | | `test_mm` | `gemm/matmul` | correctness, performance (296) | 132 | 20 | | `test_conv2d` | `convolution/conv2d` | 6 sets (257) | 87 | 12 | | `test_conv1d` | `convolution/conv1d` | 4 sets (76) | 56 | 8 | | `test_add` | `binary/add` | Not in CI; `correctness.json` unchanged | 32 | 0 | | `test_q8ta_unary` | `unary/q8ta` | Not in CI; `correctness.json` unchanged | 170 | 0 | | `test_q8ta_pixel_shuffle` | `pixel_shuffle/q8ta` | Not in CI; `correctness.json` unchanged | 32 | 0 | | `test_embedding_q4gsw` | `embedding/qw/q4gsw` | Not in CI; `correctness.json` had 33 | 28 (6 moved to the known-bug collection) | 1 | Authored with Claude Code (AI). ghstack-source-id: 463942926 @exported-using-ghexport Differential Revision: [D129408340](https://our.internmc.facebook.com/intern/diff/D129408340/) --- .../vulkan/test/ops/benchmark_v2/README.md | 10 + .../attention/sdpa/cases/correctness.json | 160 + .../convolution/conv1d/cases/correctness.json | 2567 ++++++ .../conv1d/cases/dw_correctness.json | 796 -- .../conv1d/cases/pw_correctness.json | 1129 --- .../convolution/conv2d/cases/correctness.json | 1981 +++++ .../conv2d/cases/dw_correctness.json | 698 -- .../conv2d/cases/pw_correctness.json | 378 - .../convolution/q8ta/cases/correctness.json | 7437 ++++++++++++++++- .../q8ta/cases/dw_correctness.json | 1826 ---- .../q8ta/cases/legacy_4w4c_padding.json | 368 + .../q8ta/cases/pw_correctness.json | 3355 -------- .../embedding/qw/q4gsw/cases/correctness.json | 80 +- .../cases/texture3d_multirow_indices.json | 236 + .../linear/qw/q4gsw/cases/correctness.json | 288 + .../gemm/matmul/cases/correctness.json | 1287 +++ 16 files changed, 14409 insertions(+), 8187 deletions(-) create mode 100644 backends/vulkan/test/ops/benchmark_v2/operators/convolution/conv1d/cases/correctness.json delete mode 100644 backends/vulkan/test/ops/benchmark_v2/operators/convolution/conv1d/cases/dw_correctness.json delete mode 100644 backends/vulkan/test/ops/benchmark_v2/operators/convolution/conv1d/cases/pw_correctness.json delete mode 100644 backends/vulkan/test/ops/benchmark_v2/operators/convolution/conv2d/cases/dw_correctness.json delete mode 100644 backends/vulkan/test/ops/benchmark_v2/operators/convolution/conv2d/cases/pw_correctness.json delete mode 100644 backends/vulkan/test/ops/benchmark_v2/operators/convolution/q8ta/cases/dw_correctness.json create mode 100644 backends/vulkan/test/ops/benchmark_v2/operators/convolution/q8ta/cases/legacy_4w4c_padding.json delete mode 100644 backends/vulkan/test/ops/benchmark_v2/operators/convolution/q8ta/cases/pw_correctness.json create mode 100644 backends/vulkan/test/ops/benchmark_v2/operators/embedding/qw/q4gsw/cases/texture3d_multirow_indices.json diff --git a/backends/vulkan/test/ops/benchmark_v2/README.md b/backends/vulkan/test/ops/benchmark_v2/README.md index 7aeb46e45f1..5b26f7435bf 100644 --- a/backends/vulkan/test/ops/benchmark_v2/README.md +++ b/backends/vulkan/test/ops/benchmark_v2/README.md @@ -995,6 +995,16 @@ native weight, scale, and activation arguments. Quantize and dequantize are separate operator cases. Quantized comparisons use quantized units with zero relative tolerance. Use `route` or `route_sweep` to select execution routes. +`correctness.json` is an operator directory's correctness collection, which +continuous integration validates with `--validation_only`. Besides correctness +shapes, it includes the smallest performance-collection cases that dispatch a +shader no correctness shape reaches. Performance collections exist to measure +latency and are not run in CI. Correctness cases that fail because of known +operator bugs live in separate collections named after the bug, such as +`convolution/q8ta/cases/legacy_4w4c_padding.json` and +`embedding/qw/q4gsw/cases/texture3d_multirow_indices.json`, so CI stays green +while the cases remain available for a fix. + ## Schema audits and validation-only runs `--validate_specs` parses all expanded cases and checks operator, route, reference, diff --git a/backends/vulkan/test/ops/benchmark_v2/operators/attention/sdpa/cases/correctness.json b/backends/vulkan/test/ops/benchmark_v2/operators/attention/sdpa/cases/correctness.json index 8b2a50f06ec..2ec9a0a91f8 100644 --- a/backends/vulkan/test/ops/benchmark_v2/operators/attention/sdpa/cases/correctness.json +++ b/backends/vulkan/test/ops/benchmark_v2/operators/attention/sdpa/cases/correctness.json @@ -1518,5 +1518,165 @@ } ] ] + }, + { + "name": "sdpa Llama-3.2-1B decode D64 H32 Hkv8 S1 C512 P511 float16 texture3d", + "operator": "llama.custom_sdpa.default", + "target_ms": 100, + "warmup_runs": 10, + "benchmark_runs": 30, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.01, + "rtol": 0.01 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["gqa"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "q", + "sizes": [1, 1, 32, 64], + "dtype": "float16", + "storage": "texture3d", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "k_cache", + "sizes": [1, 512, 8, 64], + "dtype": "float16", + "storage": "texture3d", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "value_cache", + "sizes": [1, 512, 8, 64], + "dtype": "float16", + "storage": "texture3d", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "int": 511 + }, + null, + { + "float": 0.0 + }, + { + "bool": true + }, + null, + { + "tensor": { + "name": "output", + "sizes": [1, 1, 32, 64], + "dtype": "float16", + "storage": "texture3d", + "layout": "width", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "sdpa Llama-3.2-1B decode D64 H32 Hkv8 S1 C512 P511 float16 texture3d", + "operator": "llama.custom_sdpa.default", + "target_ms": 100, + "warmup_runs": 10, + "benchmark_runs": 30, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.01, + "rtol": 0.01 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["non_gqa"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "q", + "sizes": [1, 1, 32, 64], + "dtype": "float16", + "storage": "texture3d", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "k_cache", + "sizes": [1, 512, 8, 64], + "dtype": "float16", + "storage": "texture3d", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "value_cache", + "sizes": [1, 512, 8, 64], + "dtype": "float16", + "storage": "texture3d", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "int": 511 + }, + null, + { + "float": 0.0 + }, + { + "bool": true + }, + null, + { + "tensor": { + "name": "output", + "sizes": [1, 1, 32, 64], + "dtype": "float16", + "storage": "texture3d", + "layout": "width", + "data": { + "fill": 0 + } + } + } + ] + ] } ] diff --git a/backends/vulkan/test/ops/benchmark_v2/operators/convolution/conv1d/cases/correctness.json b/backends/vulkan/test/ops/benchmark_v2/operators/convolution/conv1d/cases/correctness.json new file mode 100644 index 00000000000..2286941d245 --- /dev/null +++ b/backends/vulkan/test/ops/benchmark_v2/operators/convolution/conv1d/cases/correctness.json @@ -0,0 +1,2567 @@ +[ + { + "name": "conv1d pw_correctness [1, 16, 32, 64, False]", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "sweep_variables": { + "v_STORAGE": ["texture3d", "buffer"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 16, 64], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [32, 16, 1], + "dtype": "float32", + "storage": "constant", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1] + }, + { + "int_list": [0] + }, + { + "int_list": [1] + }, + { + "bool": false + }, + { + "int_list": [0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 32, 64], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv1d pw_correctness [1, 32, 16, 128, False]", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "sweep_variables": { + "v_STORAGE": ["texture3d", "buffer"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 32, 128], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [16, 32, 1], + "dtype": "float32", + "storage": "constant", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1] + }, + { + "int_list": [0] + }, + { + "int_list": [1] + }, + { + "bool": false + }, + { + "int_list": [0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 16, 128], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv1d pw_correctness [1, 64, 64, 32, False]", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "sweep_variables": { + "v_STORAGE": ["texture3d", "buffer"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 64, 32], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 64, 1], + "dtype": "float32", + "storage": "constant", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1] + }, + { + "int_list": [0] + }, + { + "int_list": [1] + }, + { + "bool": false + }, + { + "int_list": [0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 32], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv1d pw_correctness [2, 16, 32, 64, False]", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "sweep_variables": { + "v_STORAGE": ["texture3d", "buffer"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [2, 16, 64], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [32, 16, 1], + "dtype": "float32", + "storage": "constant", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1] + }, + { + "int_list": [0] + }, + { + "int_list": [1] + }, + { + "bool": false + }, + { + "int_list": [0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [2, 32, 64], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv1d pw_correctness [1, 5, 7, 64, False]", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "sweep_variables": { + "v_STORAGE": ["texture3d", "buffer"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 5, 64], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [7, 5, 1], + "dtype": "float32", + "storage": "constant", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1] + }, + { + "int_list": [0] + }, + { + "int_list": [1] + }, + { + "bool": false + }, + { + "int_list": [0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 7, 64], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv1d pw_correctness [1, 13, 17, 48, False]", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "sweep_variables": { + "v_STORAGE": ["texture3d", "buffer"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 13, 48], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [17, 13, 1], + "dtype": "float32", + "storage": "constant", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1] + }, + { + "int_list": [0] + }, + { + "int_list": [1] + }, + { + "bool": false + }, + { + "int_list": [0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 17, 48], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv1d pw_correctness [1, 7, 5, 32, False]", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "sweep_variables": { + "v_STORAGE": ["texture3d", "buffer"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 7, 32], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [5, 7, 1], + "dtype": "float32", + "storage": "constant", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1] + }, + { + "int_list": [0] + }, + { + "int_list": [1] + }, + { + "bool": false + }, + { + "int_list": [0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 5, 32], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv1d pw_correctness [1, 16, 32, 64, True]", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "sweep_variables": { + "v_STORAGE": ["texture3d", "buffer"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 16, 64], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [32, 16, 1], + "dtype": "float32", + "storage": "constant", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [32], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "int_list": [1] + }, + { + "int_list": [0] + }, + { + "int_list": [1] + }, + { + "bool": false + }, + { + "int_list": [0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 32, 64], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv1d pw_correctness [1, 32, 16, 128, True]", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "sweep_variables": { + "v_STORAGE": ["texture3d", "buffer"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 32, 128], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [16, 32, 1], + "dtype": "float32", + "storage": "constant", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [16], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "int_list": [1] + }, + { + "int_list": [0] + }, + { + "int_list": [1] + }, + { + "bool": false + }, + { + "int_list": [0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 16, 128], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv1d pw_correctness [1, 128, 256, 16, True]", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "sweep_variables": { + "v_STORAGE": ["texture3d", "buffer"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 128, 16], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [256, 128, 1], + "dtype": "float32", + "storage": "constant", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [256], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "int_list": [1] + }, + { + "int_list": [0] + }, + { + "int_list": [1] + }, + { + "bool": false + }, + { + "int_list": [0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 256, 16], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv1d pw_correctness [2, 16, 32, 64, True]", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "sweep_variables": { + "v_STORAGE": ["texture3d", "buffer"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [2, 16, 64], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [32, 16, 1], + "dtype": "float32", + "storage": "constant", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [32], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "int_list": [1] + }, + { + "int_list": [0] + }, + { + "int_list": [1] + }, + { + "bool": false + }, + { + "int_list": [0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [2, 32, 64], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv1d pw_correctness [1, 5, 7, 64, True]", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "sweep_variables": { + "v_STORAGE": ["texture3d", "buffer"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 5, 64], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [7, 5, 1], + "dtype": "float32", + "storage": "constant", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [7], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "int_list": [1] + }, + { + "int_list": [0] + }, + { + "int_list": [1] + }, + { + "bool": false + }, + { + "int_list": [0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 7, 64], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv1d pw_correctness [1, 13, 17, 48, True]", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "sweep_variables": { + "v_STORAGE": ["texture3d", "buffer"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 13, 48], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [17, 13, 1], + "dtype": "float32", + "storage": "constant", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [17], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "int_list": [1] + }, + { + "int_list": [0] + }, + { + "int_list": [1] + }, + { + "bool": false + }, + { + "int_list": [0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 17, 48], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv1d pw_correctness [2, 5, 13, 64, True]", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "sweep_variables": { + "v_STORAGE": ["texture3d", "buffer"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [2, 5, 64], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [13, 5, 1], + "dtype": "float32", + "storage": "constant", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [13], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "int_list": [1] + }, + { + "int_list": [0] + }, + { + "int_list": [1] + }, + { + "bool": false + }, + { + "int_list": [0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [2, 13, 64], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv1d dw_correctness [1, 16, 64, 3, 1, 1, 1, False]", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "sweep_variables": { + "v_STORAGE": ["texture3d", "buffer"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 16, 64], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [16, 1, 3], + "dtype": "float32", + "storage": "constant", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1] + }, + { + "int_list": [1] + }, + { + "int_list": [1] + }, + { + "bool": false + }, + { + "int_list": [0] + }, + { + "int": 16 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 16, 64], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv1d dw_correctness [1, 5, 64, 3, 1, 1, 1, False]", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "sweep_variables": { + "v_STORAGE": ["texture3d", "buffer"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 5, 64], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [5, 1, 3], + "dtype": "float32", + "storage": "constant", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1] + }, + { + "int_list": [1] + }, + { + "int_list": [1] + }, + { + "bool": false + }, + { + "int_list": [0] + }, + { + "int": 5 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 5, 64], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv1d dw_correctness [2, 7, 64, 3, 1, 1, 1, False]", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "sweep_variables": { + "v_STORAGE": ["texture3d", "buffer"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [2, 7, 64], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [7, 1, 3], + "dtype": "float32", + "storage": "constant", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1] + }, + { + "int_list": [1] + }, + { + "int_list": [1] + }, + { + "bool": false + }, + { + "int_list": [0] + }, + { + "int": 7 + }, + { + "tensor": { + "name": "output", + "sizes": [2, 7, 64], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv1d dw_correctness [1, 16, 64, 7, 1, 3, 2, False]", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "sweep_variables": { + "v_STORAGE": ["texture3d", "buffer"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 16, 64], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [16, 1, 7], + "dtype": "float32", + "storage": "constant", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1] + }, + { + "int_list": [3] + }, + { + "int_list": [2] + }, + { + "bool": false + }, + { + "int_list": [0] + }, + { + "int": 16 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 16, 58], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv1d dw_correctness [1, 7, 32, 5, 1, 2, 1, False]", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "sweep_variables": { + "v_STORAGE": ["texture3d", "buffer"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 7, 32], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [7, 1, 5], + "dtype": "float32", + "storage": "constant", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1] + }, + { + "int_list": [2] + }, + { + "int_list": [1] + }, + { + "bool": false + }, + { + "int_list": [0] + }, + { + "int": 7 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 7, 32], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv1d dw_correctness [1, 64, 32, 3, 2, 1, 1, False]", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "sweep_variables": { + "v_STORAGE": ["texture3d", "buffer"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 64, 32], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 1, 3], + "dtype": "float32", + "storage": "constant", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [2] + }, + { + "int_list": [1] + }, + { + "int_list": [1] + }, + { + "bool": false + }, + { + "int_list": [0] + }, + { + "int": 64 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 16], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv1d dw_correctness [1, 32, 128, 5, 1, 2, 1, True]", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "sweep_variables": { + "v_STORAGE": ["texture3d", "buffer"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 32, 128], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [32, 1, 5], + "dtype": "float32", + "storage": "constant", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [32], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "int_list": [1] + }, + { + "int_list": [2] + }, + { + "int_list": [1] + }, + { + "bool": false + }, + { + "int_list": [0] + }, + { + "int": 32 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 32, 128], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv1d dw_correctness [2, 16, 64, 3, 1, 1, 1, True]", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "sweep_variables": { + "v_STORAGE": ["texture3d", "buffer"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [2, 16, 64], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [16, 1, 3], + "dtype": "float32", + "storage": "constant", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [16], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "int_list": [1] + }, + { + "int_list": [1] + }, + { + "int_list": [1] + }, + { + "bool": false + }, + { + "int_list": [0] + }, + { + "int": 16 + }, + { + "tensor": { + "name": "output", + "sizes": [2, 16, 64], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv1d dw_correctness [1, 5, 64, 3, 1, 1, 1, True]", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "sweep_variables": { + "v_STORAGE": ["texture3d", "buffer"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 5, 64], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [5, 1, 3], + "dtype": "float32", + "storage": "constant", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [5], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "int_list": [1] + }, + { + "int_list": [1] + }, + { + "int_list": [1] + }, + { + "bool": false + }, + { + "int_list": [0] + }, + { + "int": 5 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 5, 64], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv1d dw_correctness [1, 13, 48, 3, 2, 1, 1, True]", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "sweep_variables": { + "v_STORAGE": ["texture3d", "buffer"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 13, 48], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [13, 1, 3], + "dtype": "float32", + "storage": "constant", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [13], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "int_list": [2] + }, + { + "int_list": [1] + }, + { + "int_list": [1] + }, + { + "bool": false + }, + { + "int_list": [0] + }, + { + "int": 13 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 13, 24], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv1d dw_performance [1, 256, 1024, 3, 1, 1, 1, False] float16 height", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.1, + "rtol": 0.01 + }, + "sweep_variables": { + "v_STORAGE": ["texture3d"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 256, 1024], + "dtype": "float16", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [256, 1, 3], + "dtype": "float16", + "storage": "constant", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1] + }, + { + "int_list": [1] + }, + { + "int_list": [1] + }, + { + "bool": false + }, + { + "int_list": [0] + }, + { + "int": 256 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 256, 1024], + "dtype": "float16", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv1d dw_performance [1, 256, 1024, 3, 1, 1, 1, False] float16 height", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.1, + "rtol": 0.01 + }, + "sweep_variables": { + "v_STORAGE": ["buffer"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 256, 1024], + "dtype": "float16", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [256, 1, 3], + "dtype": "float16", + "storage": "constant", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1] + }, + { + "int_list": [1] + }, + { + "int_list": [1] + }, + { + "bool": false + }, + { + "int_list": [0] + }, + { + "int": 256 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 256, 1024], + "dtype": "float16", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv1d pw_performance [1, 256, 512, 1024, False] float16 height", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.5, + "rtol": 0.01 + }, + "sweep_variables": { + "v_STORAGE": ["texture3d"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 256, 1024], + "dtype": "float16", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [512, 256, 1], + "dtype": "float16", + "storage": "constant", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1] + }, + { + "int_list": [0] + }, + { + "int_list": [1] + }, + { + "bool": false + }, + { + "int_list": [0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 512, 1024], + "dtype": "float16", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv1d pw_performance [1, 256, 512, 1024, False] float16 height", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.5, + "rtol": 0.01 + }, + "sweep_variables": { + "v_STORAGE": ["buffer"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 256, 1024], + "dtype": "float16", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [512, 256, 1], + "dtype": "float16", + "storage": "constant", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1] + }, + { + "int_list": [0] + }, + { + "int_list": [1] + }, + { + "bool": false + }, + { + "int_list": [0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 512, 1024], + "dtype": "float16", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv1d pw_performance [1, 256, 512, 1024, True] float16 height", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.5, + "rtol": 0.01 + }, + "sweep_variables": { + "v_STORAGE": ["texture3d"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 256, 1024], + "dtype": "float16", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [512, 256, 1], + "dtype": "float16", + "storage": "constant", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [512], + "dtype": "float16", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "int_list": [1] + }, + { + "int_list": [0] + }, + { + "int_list": [1] + }, + { + "bool": false + }, + { + "int_list": [0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 512, 1024], + "dtype": "float16", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv1d pw_performance [1, 256, 512, 1024, True] float16 height", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.5, + "rtol": 0.01 + }, + "sweep_variables": { + "v_STORAGE": ["buffer"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 256, 1024], + "dtype": "float16", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [512, 256, 1], + "dtype": "float16", + "storage": "constant", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [512], + "dtype": "float16", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "int_list": [1] + }, + { + "int_list": [0] + }, + { + "int_list": [1] + }, + { + "bool": false + }, + { + "int_list": [0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 512, 1024], + "dtype": "float16", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv1d dw_performance [1, 512, 2048, 5, 1, 2, 1, True] float16 height", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.1, + "rtol": 0.01 + }, + "sweep_variables": { + "v_STORAGE": ["texture3d"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 512, 2048], + "dtype": "float16", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [512, 1, 5], + "dtype": "float16", + "storage": "constant", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [512], + "dtype": "float16", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "int_list": [1] + }, + { + "int_list": [2] + }, + { + "int_list": [1] + }, + { + "bool": false + }, + { + "int_list": [0] + }, + { + "int": 512 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 512, 2048], + "dtype": "float16", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv1d dw_performance [1, 512, 2048, 5, 1, 2, 1, True] float16 height", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.1, + "rtol": 0.01 + }, + "sweep_variables": { + "v_STORAGE": ["buffer"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 512, 2048], + "dtype": "float16", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [512, 1, 5], + "dtype": "float16", + "storage": "constant", + "layout": "height", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [512], + "dtype": "float16", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "int_list": [1] + }, + { + "int_list": [2] + }, + { + "int_list": [1] + }, + { + "bool": false + }, + { + "int_list": [0] + }, + { + "int": 512 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 512, 2048], + "dtype": "float16", + "storage": "v_STORAGE", + "layout": "height", + "data": { + "fill": 0 + } + } + } + ] + ] + } +] diff --git a/backends/vulkan/test/ops/benchmark_v2/operators/convolution/conv1d/cases/dw_correctness.json b/backends/vulkan/test/ops/benchmark_v2/operators/convolution/conv1d/cases/dw_correctness.json deleted file mode 100644 index 08eb7a17655..00000000000 --- a/backends/vulkan/test/ops/benchmark_v2/operators/convolution/conv1d/cases/dw_correctness.json +++ /dev/null @@ -1,796 +0,0 @@ -[ - { - "name": "conv1d dw_correctness [1, 16, 64, 3, 1, 1, 1, False]", - "operator": "aten.convolution.default", - "target_ms": 100, - "warmup_runs": 1, - "benchmark_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 0.001, - "rtol": 0.001 - }, - "sweep_variables": { - "v_STORAGE": ["texture3d", "buffer"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 16, 64], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "weight", - "sizes": [16, 1, 3], - "dtype": "float32", - "storage": "constant", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - null, - { - "int_list": [1] - }, - { - "int_list": [1] - }, - { - "int_list": [1] - }, - { - "bool": false - }, - { - "int_list": [0] - }, - { - "int": 16 - }, - { - "tensor": { - "name": "output", - "sizes": [1, 16, 64], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "conv1d dw_correctness [1, 5, 64, 3, 1, 1, 1, False]", - "operator": "aten.convolution.default", - "target_ms": 100, - "warmup_runs": 1, - "benchmark_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 0.001, - "rtol": 0.001 - }, - "sweep_variables": { - "v_STORAGE": ["texture3d", "buffer"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 5, 64], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "weight", - "sizes": [5, 1, 3], - "dtype": "float32", - "storage": "constant", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - null, - { - "int_list": [1] - }, - { - "int_list": [1] - }, - { - "int_list": [1] - }, - { - "bool": false - }, - { - "int_list": [0] - }, - { - "int": 5 - }, - { - "tensor": { - "name": "output", - "sizes": [1, 5, 64], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "conv1d dw_correctness [2, 7, 64, 3, 1, 1, 1, False]", - "operator": "aten.convolution.default", - "target_ms": 100, - "warmup_runs": 1, - "benchmark_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 0.001, - "rtol": 0.001 - }, - "sweep_variables": { - "v_STORAGE": ["texture3d", "buffer"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [2, 7, 64], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "weight", - "sizes": [7, 1, 3], - "dtype": "float32", - "storage": "constant", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - null, - { - "int_list": [1] - }, - { - "int_list": [1] - }, - { - "int_list": [1] - }, - { - "bool": false - }, - { - "int_list": [0] - }, - { - "int": 7 - }, - { - "tensor": { - "name": "output", - "sizes": [2, 7, 64], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "conv1d dw_correctness [1, 16, 64, 7, 1, 3, 2, False]", - "operator": "aten.convolution.default", - "target_ms": 100, - "warmup_runs": 1, - "benchmark_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 0.001, - "rtol": 0.001 - }, - "sweep_variables": { - "v_STORAGE": ["texture3d", "buffer"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 16, 64], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "weight", - "sizes": [16, 1, 7], - "dtype": "float32", - "storage": "constant", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - null, - { - "int_list": [1] - }, - { - "int_list": [3] - }, - { - "int_list": [2] - }, - { - "bool": false - }, - { - "int_list": [0] - }, - { - "int": 16 - }, - { - "tensor": { - "name": "output", - "sizes": [1, 16, 58], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "conv1d dw_correctness [1, 7, 32, 5, 1, 2, 1, False]", - "operator": "aten.convolution.default", - "target_ms": 100, - "warmup_runs": 1, - "benchmark_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 0.001, - "rtol": 0.001 - }, - "sweep_variables": { - "v_STORAGE": ["texture3d", "buffer"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 7, 32], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "weight", - "sizes": [7, 1, 5], - "dtype": "float32", - "storage": "constant", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - null, - { - "int_list": [1] - }, - { - "int_list": [2] - }, - { - "int_list": [1] - }, - { - "bool": false - }, - { - "int_list": [0] - }, - { - "int": 7 - }, - { - "tensor": { - "name": "output", - "sizes": [1, 7, 32], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "conv1d dw_correctness [1, 64, 32, 3, 2, 1, 1, False]", - "operator": "aten.convolution.default", - "target_ms": 100, - "warmup_runs": 1, - "benchmark_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 0.001, - "rtol": 0.001 - }, - "sweep_variables": { - "v_STORAGE": ["texture3d", "buffer"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 64, 32], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "weight", - "sizes": [64, 1, 3], - "dtype": "float32", - "storage": "constant", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - null, - { - "int_list": [2] - }, - { - "int_list": [1] - }, - { - "int_list": [1] - }, - { - "bool": false - }, - { - "int_list": [0] - }, - { - "int": 64 - }, - { - "tensor": { - "name": "output", - "sizes": [1, 64, 16], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "conv1d dw_correctness [1, 32, 128, 5, 1, 2, 1, True]", - "operator": "aten.convolution.default", - "target_ms": 100, - "warmup_runs": 1, - "benchmark_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 0.001, - "rtol": 0.001 - }, - "sweep_variables": { - "v_STORAGE": ["texture3d", "buffer"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 32, 128], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "weight", - "sizes": [32, 1, 5], - "dtype": "float32", - "storage": "constant", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "bias", - "sizes": [32], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "int_list": [1] - }, - { - "int_list": [2] - }, - { - "int_list": [1] - }, - { - "bool": false - }, - { - "int_list": [0] - }, - { - "int": 32 - }, - { - "tensor": { - "name": "output", - "sizes": [1, 32, 128], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "conv1d dw_correctness [2, 16, 64, 3, 1, 1, 1, True]", - "operator": "aten.convolution.default", - "target_ms": 100, - "warmup_runs": 1, - "benchmark_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 0.001, - "rtol": 0.001 - }, - "sweep_variables": { - "v_STORAGE": ["texture3d", "buffer"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [2, 16, 64], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "weight", - "sizes": [16, 1, 3], - "dtype": "float32", - "storage": "constant", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "bias", - "sizes": [16], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "int_list": [1] - }, - { - "int_list": [1] - }, - { - "int_list": [1] - }, - { - "bool": false - }, - { - "int_list": [0] - }, - { - "int": 16 - }, - { - "tensor": { - "name": "output", - "sizes": [2, 16, 64], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "conv1d dw_correctness [1, 5, 64, 3, 1, 1, 1, True]", - "operator": "aten.convolution.default", - "target_ms": 100, - "warmup_runs": 1, - "benchmark_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 0.001, - "rtol": 0.001 - }, - "sweep_variables": { - "v_STORAGE": ["texture3d", "buffer"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 5, 64], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "weight", - "sizes": [5, 1, 3], - "dtype": "float32", - "storage": "constant", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "bias", - "sizes": [5], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "int_list": [1] - }, - { - "int_list": [1] - }, - { - "int_list": [1] - }, - { - "bool": false - }, - { - "int_list": [0] - }, - { - "int": 5 - }, - { - "tensor": { - "name": "output", - "sizes": [1, 5, 64], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "conv1d dw_correctness [1, 13, 48, 3, 2, 1, 1, True]", - "operator": "aten.convolution.default", - "target_ms": 100, - "warmup_runs": 1, - "benchmark_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 0.001, - "rtol": 0.001 - }, - "sweep_variables": { - "v_STORAGE": ["texture3d", "buffer"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 13, 48], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "weight", - "sizes": [13, 1, 3], - "dtype": "float32", - "storage": "constant", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "bias", - "sizes": [13], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "int_list": [2] - }, - { - "int_list": [1] - }, - { - "int_list": [1] - }, - { - "bool": false - }, - { - "int_list": [0] - }, - { - "int": 13 - }, - { - "tensor": { - "name": "output", - "sizes": [1, 13, 24], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "fill": 0 - } - } - } - ] - ] - } -] diff --git a/backends/vulkan/test/ops/benchmark_v2/operators/convolution/conv1d/cases/pw_correctness.json b/backends/vulkan/test/ops/benchmark_v2/operators/convolution/conv1d/cases/pw_correctness.json deleted file mode 100644 index c1fee9a524d..00000000000 --- a/backends/vulkan/test/ops/benchmark_v2/operators/convolution/conv1d/cases/pw_correctness.json +++ /dev/null @@ -1,1129 +0,0 @@ -[ - { - "name": "conv1d pw_correctness [1, 16, 32, 64, False]", - "operator": "aten.convolution.default", - "target_ms": 100, - "warmup_runs": 1, - "benchmark_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 0.001, - "rtol": 0.001 - }, - "sweep_variables": { - "v_STORAGE": ["texture3d", "buffer"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 16, 64], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "weight", - "sizes": [32, 16, 1], - "dtype": "float32", - "storage": "constant", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - null, - { - "int_list": [1] - }, - { - "int_list": [0] - }, - { - "int_list": [1] - }, - { - "bool": false - }, - { - "int_list": [0] - }, - { - "int": 1 - }, - { - "tensor": { - "name": "output", - "sizes": [1, 32, 64], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "conv1d pw_correctness [1, 32, 16, 128, False]", - "operator": "aten.convolution.default", - "target_ms": 100, - "warmup_runs": 1, - "benchmark_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 0.001, - "rtol": 0.001 - }, - "sweep_variables": { - "v_STORAGE": ["texture3d", "buffer"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 32, 128], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "weight", - "sizes": [16, 32, 1], - "dtype": "float32", - "storage": "constant", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - null, - { - "int_list": [1] - }, - { - "int_list": [0] - }, - { - "int_list": [1] - }, - { - "bool": false - }, - { - "int_list": [0] - }, - { - "int": 1 - }, - { - "tensor": { - "name": "output", - "sizes": [1, 16, 128], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "conv1d pw_correctness [1, 64, 64, 32, False]", - "operator": "aten.convolution.default", - "target_ms": 100, - "warmup_runs": 1, - "benchmark_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 0.001, - "rtol": 0.001 - }, - "sweep_variables": { - "v_STORAGE": ["texture3d", "buffer"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 64, 32], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "weight", - "sizes": [64, 64, 1], - "dtype": "float32", - "storage": "constant", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - null, - { - "int_list": [1] - }, - { - "int_list": [0] - }, - { - "int_list": [1] - }, - { - "bool": false - }, - { - "int_list": [0] - }, - { - "int": 1 - }, - { - "tensor": { - "name": "output", - "sizes": [1, 64, 32], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "conv1d pw_correctness [2, 16, 32, 64, False]", - "operator": "aten.convolution.default", - "target_ms": 100, - "warmup_runs": 1, - "benchmark_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 0.001, - "rtol": 0.001 - }, - "sweep_variables": { - "v_STORAGE": ["texture3d", "buffer"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [2, 16, 64], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "weight", - "sizes": [32, 16, 1], - "dtype": "float32", - "storage": "constant", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - null, - { - "int_list": [1] - }, - { - "int_list": [0] - }, - { - "int_list": [1] - }, - { - "bool": false - }, - { - "int_list": [0] - }, - { - "int": 1 - }, - { - "tensor": { - "name": "output", - "sizes": [2, 32, 64], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "conv1d pw_correctness [1, 5, 7, 64, False]", - "operator": "aten.convolution.default", - "target_ms": 100, - "warmup_runs": 1, - "benchmark_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 0.001, - "rtol": 0.001 - }, - "sweep_variables": { - "v_STORAGE": ["texture3d", "buffer"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 5, 64], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "weight", - "sizes": [7, 5, 1], - "dtype": "float32", - "storage": "constant", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - null, - { - "int_list": [1] - }, - { - "int_list": [0] - }, - { - "int_list": [1] - }, - { - "bool": false - }, - { - "int_list": [0] - }, - { - "int": 1 - }, - { - "tensor": { - "name": "output", - "sizes": [1, 7, 64], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "conv1d pw_correctness [1, 13, 17, 48, False]", - "operator": "aten.convolution.default", - "target_ms": 100, - "warmup_runs": 1, - "benchmark_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 0.001, - "rtol": 0.001 - }, - "sweep_variables": { - "v_STORAGE": ["texture3d", "buffer"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 13, 48], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "weight", - "sizes": [17, 13, 1], - "dtype": "float32", - "storage": "constant", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - null, - { - "int_list": [1] - }, - { - "int_list": [0] - }, - { - "int_list": [1] - }, - { - "bool": false - }, - { - "int_list": [0] - }, - { - "int": 1 - }, - { - "tensor": { - "name": "output", - "sizes": [1, 17, 48], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "conv1d pw_correctness [1, 7, 5, 32, False]", - "operator": "aten.convolution.default", - "target_ms": 100, - "warmup_runs": 1, - "benchmark_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 0.001, - "rtol": 0.001 - }, - "sweep_variables": { - "v_STORAGE": ["texture3d", "buffer"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 7, 32], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "weight", - "sizes": [5, 7, 1], - "dtype": "float32", - "storage": "constant", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - null, - { - "int_list": [1] - }, - { - "int_list": [0] - }, - { - "int_list": [1] - }, - { - "bool": false - }, - { - "int_list": [0] - }, - { - "int": 1 - }, - { - "tensor": { - "name": "output", - "sizes": [1, 5, 32], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "conv1d pw_correctness [1, 16, 32, 64, True]", - "operator": "aten.convolution.default", - "target_ms": 100, - "warmup_runs": 1, - "benchmark_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 0.001, - "rtol": 0.001 - }, - "sweep_variables": { - "v_STORAGE": ["texture3d", "buffer"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 16, 64], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "weight", - "sizes": [32, 16, 1], - "dtype": "float32", - "storage": "constant", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "bias", - "sizes": [32], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "int_list": [1] - }, - { - "int_list": [0] - }, - { - "int_list": [1] - }, - { - "bool": false - }, - { - "int_list": [0] - }, - { - "int": 1 - }, - { - "tensor": { - "name": "output", - "sizes": [1, 32, 64], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "conv1d pw_correctness [1, 32, 16, 128, True]", - "operator": "aten.convolution.default", - "target_ms": 100, - "warmup_runs": 1, - "benchmark_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 0.001, - "rtol": 0.001 - }, - "sweep_variables": { - "v_STORAGE": ["texture3d", "buffer"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 32, 128], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "weight", - "sizes": [16, 32, 1], - "dtype": "float32", - "storage": "constant", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "bias", - "sizes": [16], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "int_list": [1] - }, - { - "int_list": [0] - }, - { - "int_list": [1] - }, - { - "bool": false - }, - { - "int_list": [0] - }, - { - "int": 1 - }, - { - "tensor": { - "name": "output", - "sizes": [1, 16, 128], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "conv1d pw_correctness [1, 128, 256, 16, True]", - "operator": "aten.convolution.default", - "target_ms": 100, - "warmup_runs": 1, - "benchmark_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 0.001, - "rtol": 0.001 - }, - "sweep_variables": { - "v_STORAGE": ["texture3d", "buffer"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 128, 16], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "weight", - "sizes": [256, 128, 1], - "dtype": "float32", - "storage": "constant", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "bias", - "sizes": [256], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "int_list": [1] - }, - { - "int_list": [0] - }, - { - "int_list": [1] - }, - { - "bool": false - }, - { - "int_list": [0] - }, - { - "int": 1 - }, - { - "tensor": { - "name": "output", - "sizes": [1, 256, 16], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "conv1d pw_correctness [2, 16, 32, 64, True]", - "operator": "aten.convolution.default", - "target_ms": 100, - "warmup_runs": 1, - "benchmark_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 0.001, - "rtol": 0.001 - }, - "sweep_variables": { - "v_STORAGE": ["texture3d", "buffer"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [2, 16, 64], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "weight", - "sizes": [32, 16, 1], - "dtype": "float32", - "storage": "constant", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "bias", - "sizes": [32], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "int_list": [1] - }, - { - "int_list": [0] - }, - { - "int_list": [1] - }, - { - "bool": false - }, - { - "int_list": [0] - }, - { - "int": 1 - }, - { - "tensor": { - "name": "output", - "sizes": [2, 32, 64], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "conv1d pw_correctness [1, 5, 7, 64, True]", - "operator": "aten.convolution.default", - "target_ms": 100, - "warmup_runs": 1, - "benchmark_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 0.001, - "rtol": 0.001 - }, - "sweep_variables": { - "v_STORAGE": ["texture3d", "buffer"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 5, 64], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "weight", - "sizes": [7, 5, 1], - "dtype": "float32", - "storage": "constant", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "bias", - "sizes": [7], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "int_list": [1] - }, - { - "int_list": [0] - }, - { - "int_list": [1] - }, - { - "bool": false - }, - { - "int_list": [0] - }, - { - "int": 1 - }, - { - "tensor": { - "name": "output", - "sizes": [1, 7, 64], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "conv1d pw_correctness [1, 13, 17, 48, True]", - "operator": "aten.convolution.default", - "target_ms": 100, - "warmup_runs": 1, - "benchmark_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 0.001, - "rtol": 0.001 - }, - "sweep_variables": { - "v_STORAGE": ["texture3d", "buffer"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 13, 48], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "weight", - "sizes": [17, 13, 1], - "dtype": "float32", - "storage": "constant", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "bias", - "sizes": [17], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "int_list": [1] - }, - { - "int_list": [0] - }, - { - "int_list": [1] - }, - { - "bool": false - }, - { - "int_list": [0] - }, - { - "int": 1 - }, - { - "tensor": { - "name": "output", - "sizes": [1, 17, 48], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "conv1d pw_correctness [2, 5, 13, 64, True]", - "operator": "aten.convolution.default", - "target_ms": 100, - "warmup_runs": 1, - "benchmark_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 0.001, - "rtol": 0.001 - }, - "sweep_variables": { - "v_STORAGE": ["texture3d", "buffer"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [2, 5, 64], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "weight", - "sizes": [13, 5, 1], - "dtype": "float32", - "storage": "constant", - "layout": "height", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "bias", - "sizes": [13], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "int_list": [1] - }, - { - "int_list": [0] - }, - { - "int_list": [1] - }, - { - "bool": false - }, - { - "int_list": [0] - }, - { - "int": 1 - }, - { - "tensor": { - "name": "output", - "sizes": [2, 13, 64], - "dtype": "float32", - "storage": "v_STORAGE", - "layout": "height", - "data": { - "fill": 0 - } - } - } - ] - ] - } -] diff --git a/backends/vulkan/test/ops/benchmark_v2/operators/convolution/conv2d/cases/correctness.json b/backends/vulkan/test/ops/benchmark_v2/operators/convolution/conv2d/cases/correctness.json index d131e8f7a51..a0c91bb7334 100644 --- a/backends/vulkan/test/ops/benchmark_v2/operators/convolution/conv2d/cases/correctness.json +++ b/backends/vulkan/test/ops/benchmark_v2/operators/convolution/conv2d/cases/correctness.json @@ -1460,5 +1460,1986 @@ } ] ] + }, + { + "name": "conv2d pw_correctness [1, 16, 32, 8, 8, False] float32 auto", + "operator": "aten.convolution.default", + "route": "auto", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 16, 8, 8], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [32, 16, 1, 1], + "dtype": "float32", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 32, 8, 8], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d pw_correctness [1, 32, 16, 8, 8, False] float32 auto", + "operator": "aten.convolution.default", + "route": "auto", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 32, 8, 8], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [16, 32, 1, 1], + "dtype": "float32", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 16, 8, 8], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d pw_correctness [1, 16, 32, 8, 8, True] float32 auto", + "operator": "aten.convolution.default", + "route": "auto", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 16, 8, 8], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [32, 16, 1, 1], + "dtype": "float32", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [32], + "dtype": "float32", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 32, 8, 8], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d pw_correctness [1, 13, 27, 8, 8, False] float32 auto", + "operator": "aten.convolution.default", + "route": "auto", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 13, 8, 8], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [27, 13, 1, 1], + "dtype": "float32", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 27, 8, 8], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d pw_correctness [1, 33, 17, 8, 8, False] float32 auto", + "operator": "aten.convolution.default", + "route": "auto", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 33, 8, 8], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [17, 33, 1, 1], + "dtype": "float32", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 17, 8, 8], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d dw_correctness [[1, 8, 16, 16], [3, 3], [1, 1], [1, 1], [1, 1], False] float32 auto", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "route": "auto", + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 8, 16, 16], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [8, 1, 3, 3], + "dtype": "float32", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 8 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 8, 16, 16], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d dw_correctness [[1, 11, 16, 16], [3, 3], [1, 1], [1, 1], [1, 1], False] float32 auto", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "route": "auto", + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 11, 16, 16], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [11, 1, 3, 3], + "dtype": "float32", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 11 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 11, 16, 16], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d dw_correctness [[1, 3, 16, 16], [3, 3], [1, 1], [1, 1], [1, 1], False] float32 auto", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "route": "auto", + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 3, 16, 16], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [3, 1, 3, 3], + "dtype": "float32", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 3 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 3, 16, 16], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d dw_correctness [[1, 8, 16, 16], [5, 5], [1, 1], [2, 2], [1, 1], False] float32 auto", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "route": "auto", + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 8, 16, 16], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [8, 1, 5, 5], + "dtype": "float32", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 8 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 8, 16, 16], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d dw_correctness [[1, 8, 16, 16],", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "route": "auto", + "sweep_variables": { + "v_WEIGHT_SIZES": [ + [8, 1, 3, 3], + [8, 1, 4, 4] + ] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 8, 16, 16], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": "v_WEIGHT_SIZES", + "dtype": "float32", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 8 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 8, 8, 8], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d dw_correctness [[1, 8, 32, 32], [6, 6], [3, 3], [2, 2], [3, 3], False] float32 auto", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "route": "auto", + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 8, 32, 32], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [8, 1, 6, 6], + "dtype": "float32", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [3, 3] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [3, 3] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 8 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 8, 7, 7], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d dw_correctness [[1, 8, 16, 16], [3, 3], [1, 1], [1, 1], [1, 1], True] float32 auto", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "route": "auto", + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 8, 16, 16], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [8, 1, 3, 3], + "dtype": "float32", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [8], + "dtype": "float32", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 8 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 8, 16, 16], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d dw_correctness [[1, 8, 32, 32], [7, 7], [1, 1], [6, 6], [2, 2], True] float32 auto", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "route": "auto", + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 8, 32, 32], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [8, 1, 7, 7], + "dtype": "float32", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [8], + "dtype": "float32", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [6, 6] + }, + { + "int_list": [2, 2] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 8 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 8, 32, 32], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d dw_correctness [[1, 8, 31, 37], [3, 5], [2, 1], [1, 2], [1, 1], True] float32 auto", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "route": "auto", + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 8, 31, 37], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [8, 1, 3, 5], + "dtype": "float32", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [8], + "dtype": "float32", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "int_list": [2, 1] + }, + { + "int_list": [1, 2] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 8 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 8, 16, 37], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d performance [[1, 64, 36, 48], 32, [3, 3], [1, 1], [1, 1], [1, 1], False] float16", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 5, + "benchmark_runs": 20, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0, + "rtol": 0.01 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["auto"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 64, 36, 48], + "dtype": "float16", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [32, 64, 3, 3], + "dtype": "float16", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 32, 36, 48], + "dtype": "float16", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d performance [[1, 64, 36, 48], 32, [3, 3], [1, 1], [1, 1], [1, 1], False] float32", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 5, + "benchmark_runs": 20, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["auto"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 64, 36, 48], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [32, 64, 3, 3], + "dtype": "float32", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 32, 36, 48], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d dw_performance [[1, 384, 32, 32], [3, 3], [1, 1], [1, 1], [1, 1], False] float16", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.1, + "rtol": 0.01 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["b1x1"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 384, 32, 32], + "dtype": "float16", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [384, 1, 3, 3], + "dtype": "float16", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 384 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 384, 32, 32], + "dtype": "float16", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d performance [[1, 32, 72, 96], 32, [3, 3], [2, 2], [1, 1], [1, 1], False] float16", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 5, + "benchmark_runs": 20, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.5, + "rtol": 0.01 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["direct"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 32, 72, 96], + "dtype": "float16", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [32, 32, 3, 3], + "dtype": "float16", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 32, 36, 48], + "dtype": "float16", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d dw_performance [[1, 384, 32, 32], [3, 3], [1, 1], [1, 1], [1, 1], False] float16", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.1, + "rtol": 0.01 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["b4x2"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 384, 32, 32], + "dtype": "float16", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [384, 1, 3, 3], + "dtype": "float16", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 384 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 384, 32, 32], + "dtype": "float16", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d dw_performance [[1, 192, 64, 64], [3, 3], [2, 2], [1, 1], [1, 1], False] float16", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.1, + "rtol": 0.01 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["auto"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 192, 64, 64], + "dtype": "float16", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [192, 1, 3, 3], + "dtype": "float16", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 192 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 192, 32, 32], + "dtype": "float16", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d pw_performance [1, 384, 768, 32, 32, False] float16 auto", + "operator": "aten.convolution.default", + "route": "auto", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.1, + "rtol": 0.01 + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 384, 32, 32], + "dtype": "float16", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [768, 384, 1, 1], + "dtype": "float16", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 768, 32, 32], + "dtype": "float16", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d dw_performance [[1, 384, 32, 32], [3, 3], [1, 1], [1, 1], [1, 1], False] float32", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["b4x2"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 384, 32, 32], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [384, 1, 3, 3], + "dtype": "float32", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 384 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 384, 32, 32], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d dw_performance [[1, 384, 32, 32], [3, 3], [1, 1], [1, 1], [1, 1], False] float32", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["b1x1"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 384, 32, 32], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [384, 1, 3, 3], + "dtype": "float32", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 384 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 384, 32, 32], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d dw_performance [[1, 96, 128, 128], [5, 5], [1, 1], [2, 2], [1, 1], False] float16", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.1, + "rtol": 0.01 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["auto"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 96, 128, 128], + "dtype": "float16", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [96, 1, 5, 5], + "dtype": "float16", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 96 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 96, 128, 128], + "dtype": "float16", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d dw_performance [[1, 96, 128, 128], [5, 5], [1, 1], [2, 2], [1, 1], False] float16", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.1, + "rtol": 0.01 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["b4x2"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 96, 128, 128], + "dtype": "float16", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [96, 1, 5, 5], + "dtype": "float16", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 96 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 96, 128, 128], + "dtype": "float16", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d dw_performance [[1, 96, 128, 128], [5, 5], [1, 1], [2, 2], [1, 1], False] float32", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["b4x2"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 96, 128, 128], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [96, 1, 5, 5], + "dtype": "float32", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 96 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 96, 128, 128], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] } ] diff --git a/backends/vulkan/test/ops/benchmark_v2/operators/convolution/conv2d/cases/dw_correctness.json b/backends/vulkan/test/ops/benchmark_v2/operators/convolution/conv2d/cases/dw_correctness.json deleted file mode 100644 index 52abbb085d9..00000000000 --- a/backends/vulkan/test/ops/benchmark_v2/operators/convolution/conv2d/cases/dw_correctness.json +++ /dev/null @@ -1,698 +0,0 @@ -[ - { - "name": "conv2d dw_correctness [[1, 8, 16, 16], [3, 3], [1, 1], [1, 1], [1, 1], False] float32 auto", - "operator": "aten.convolution.default", - "target_ms": 100, - "warmup_runs": 1, - "benchmark_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 0.001, - "rtol": 0.001 - }, - "route": "auto", - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 8, 16, 16], - "dtype": "float32", - "storage": "texture3d", - "layout": "channels", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "weight", - "sizes": [8, 1, 3, 3], - "dtype": "float32", - "storage": "constant", - "layout": "channels", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - null, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "bool": false - }, - { - "int_list": [0, 0] - }, - { - "int": 8 - }, - { - "tensor": { - "name": "output", - "sizes": [1, 8, 16, 16], - "dtype": "float32", - "storage": "texture3d", - "layout": "channels", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "conv2d dw_correctness [[1, 11, 16, 16], [3, 3], [1, 1], [1, 1], [1, 1], False] float32 auto", - "operator": "aten.convolution.default", - "target_ms": 100, - "warmup_runs": 1, - "benchmark_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 0.001, - "rtol": 0.001 - }, - "route": "auto", - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 11, 16, 16], - "dtype": "float32", - "storage": "texture3d", - "layout": "channels", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "weight", - "sizes": [11, 1, 3, 3], - "dtype": "float32", - "storage": "constant", - "layout": "channels", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - null, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "bool": false - }, - { - "int_list": [0, 0] - }, - { - "int": 11 - }, - { - "tensor": { - "name": "output", - "sizes": [1, 11, 16, 16], - "dtype": "float32", - "storage": "texture3d", - "layout": "channels", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "conv2d dw_correctness [[1, 3, 16, 16], [3, 3], [1, 1], [1, 1], [1, 1], False] float32 auto", - "operator": "aten.convolution.default", - "target_ms": 100, - "warmup_runs": 1, - "benchmark_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 0.001, - "rtol": 0.001 - }, - "route": "auto", - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 3, 16, 16], - "dtype": "float32", - "storage": "texture3d", - "layout": "channels", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "weight", - "sizes": [3, 1, 3, 3], - "dtype": "float32", - "storage": "constant", - "layout": "channels", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - null, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "bool": false - }, - { - "int_list": [0, 0] - }, - { - "int": 3 - }, - { - "tensor": { - "name": "output", - "sizes": [1, 3, 16, 16], - "dtype": "float32", - "storage": "texture3d", - "layout": "channels", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "conv2d dw_correctness [[1, 8, 16, 16], [5, 5], [1, 1], [2, 2], [1, 1], False] float32 auto", - "operator": "aten.convolution.default", - "target_ms": 100, - "warmup_runs": 1, - "benchmark_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 0.001, - "rtol": 0.001 - }, - "route": "auto", - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 8, 16, 16], - "dtype": "float32", - "storage": "texture3d", - "layout": "channels", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "weight", - "sizes": [8, 1, 5, 5], - "dtype": "float32", - "storage": "constant", - "layout": "channels", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - null, - { - "int_list": [1, 1] - }, - { - "int_list": [2, 2] - }, - { - "int_list": [1, 1] - }, - { - "bool": false - }, - { - "int_list": [0, 0] - }, - { - "int": 8 - }, - { - "tensor": { - "name": "output", - "sizes": [1, 8, 16, 16], - "dtype": "float32", - "storage": "texture3d", - "layout": "channels", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "conv2d dw_correctness [[1, 8, 16, 16],", - "operator": "aten.convolution.default", - "target_ms": 100, - "warmup_runs": 1, - "benchmark_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 0.001, - "rtol": 0.001 - }, - "route": "auto", - "sweep_variables": { - "v_WEIGHT_SIZES": [ - [8, 1, 3, 3], - [8, 1, 4, 4] - ] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 8, 16, 16], - "dtype": "float32", - "storage": "texture3d", - "layout": "channels", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "weight", - "sizes": "v_WEIGHT_SIZES", - "dtype": "float32", - "storage": "constant", - "layout": "channels", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - null, - { - "int_list": [2, 2] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "bool": false - }, - { - "int_list": [0, 0] - }, - { - "int": 8 - }, - { - "tensor": { - "name": "output", - "sizes": [1, 8, 8, 8], - "dtype": "float32", - "storage": "texture3d", - "layout": "channels", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "conv2d dw_correctness [[1, 8, 32, 32], [6, 6], [3, 3], [2, 2], [3, 3], False] float32 auto", - "operator": "aten.convolution.default", - "target_ms": 100, - "warmup_runs": 1, - "benchmark_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 0.001, - "rtol": 0.001 - }, - "route": "auto", - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 8, 32, 32], - "dtype": "float32", - "storage": "texture3d", - "layout": "channels", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "weight", - "sizes": [8, 1, 6, 6], - "dtype": "float32", - "storage": "constant", - "layout": "channels", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - null, - { - "int_list": [3, 3] - }, - { - "int_list": [2, 2] - }, - { - "int_list": [3, 3] - }, - { - "bool": false - }, - { - "int_list": [0, 0] - }, - { - "int": 8 - }, - { - "tensor": { - "name": "output", - "sizes": [1, 8, 7, 7], - "dtype": "float32", - "storage": "texture3d", - "layout": "channels", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "conv2d dw_correctness [[1, 8, 16, 16], [3, 3], [1, 1], [1, 1], [1, 1], True] float32 auto", - "operator": "aten.convolution.default", - "target_ms": 100, - "warmup_runs": 1, - "benchmark_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 0.001, - "rtol": 0.001 - }, - "route": "auto", - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 8, 16, 16], - "dtype": "float32", - "storage": "texture3d", - "layout": "channels", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "weight", - "sizes": [8, 1, 3, 3], - "dtype": "float32", - "storage": "constant", - "layout": "channels", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "bias", - "sizes": [8], - "dtype": "float32", - "storage": "constant", - "layout": "channels", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "bool": false - }, - { - "int_list": [0, 0] - }, - { - "int": 8 - }, - { - "tensor": { - "name": "output", - "sizes": [1, 8, 16, 16], - "dtype": "float32", - "storage": "texture3d", - "layout": "channels", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "conv2d dw_correctness [[1, 8, 32, 32], [7, 7], [1, 1], [6, 6], [2, 2], True] float32 auto", - "operator": "aten.convolution.default", - "target_ms": 100, - "warmup_runs": 1, - "benchmark_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 0.001, - "rtol": 0.001 - }, - "route": "auto", - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 8, 32, 32], - "dtype": "float32", - "storage": "texture3d", - "layout": "channels", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "weight", - "sizes": [8, 1, 7, 7], - "dtype": "float32", - "storage": "constant", - "layout": "channels", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "bias", - "sizes": [8], - "dtype": "float32", - "storage": "constant", - "layout": "channels", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "int_list": [1, 1] - }, - { - "int_list": [6, 6] - }, - { - "int_list": [2, 2] - }, - { - "bool": false - }, - { - "int_list": [0, 0] - }, - { - "int": 8 - }, - { - "tensor": { - "name": "output", - "sizes": [1, 8, 32, 32], - "dtype": "float32", - "storage": "texture3d", - "layout": "channels", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "conv2d dw_correctness [[1, 8, 31, 37], [3, 5], [2, 1], [1, 2], [1, 1], True] float32 auto", - "operator": "aten.convolution.default", - "target_ms": 100, - "warmup_runs": 1, - "benchmark_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 0.001, - "rtol": 0.001 - }, - "route": "auto", - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 8, 31, 37], - "dtype": "float32", - "storage": "texture3d", - "layout": "channels", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "weight", - "sizes": [8, 1, 3, 5], - "dtype": "float32", - "storage": "constant", - "layout": "channels", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "bias", - "sizes": [8], - "dtype": "float32", - "storage": "constant", - "layout": "channels", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "int_list": [2, 1] - }, - { - "int_list": [1, 2] - }, - { - "int_list": [1, 1] - }, - { - "bool": false - }, - { - "int_list": [0, 0] - }, - { - "int": 8 - }, - { - "tensor": { - "name": "output", - "sizes": [1, 8, 16, 37], - "dtype": "float32", - "storage": "texture3d", - "layout": "channels", - "data": { - "fill": 0 - } - } - } - ] - ] - } -] diff --git a/backends/vulkan/test/ops/benchmark_v2/operators/convolution/conv2d/cases/pw_correctness.json b/backends/vulkan/test/ops/benchmark_v2/operators/convolution/conv2d/cases/pw_correctness.json deleted file mode 100644 index a6c2ea90a98..00000000000 --- a/backends/vulkan/test/ops/benchmark_v2/operators/convolution/conv2d/cases/pw_correctness.json +++ /dev/null @@ -1,378 +0,0 @@ -[ - { - "name": "conv2d pw_correctness [1, 16, 32, 8, 8, False] float32 auto", - "operator": "aten.convolution.default", - "route": "auto", - "target_ms": 100, - "warmup_runs": 1, - "benchmark_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 0.001, - "rtol": 0.001 - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 16, 8, 8], - "dtype": "float32", - "storage": "texture3d", - "layout": "channels", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "weight", - "sizes": [32, 16, 1, 1], - "dtype": "float32", - "storage": "constant", - "layout": "channels", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - null, - { - "int_list": [1, 1] - }, - { - "int_list": [0, 0] - }, - { - "int_list": [1, 1] - }, - { - "bool": false - }, - { - "int_list": [0, 0] - }, - { - "int": 1 - }, - { - "tensor": { - "name": "output", - "sizes": [1, 32, 8, 8], - "dtype": "float32", - "storage": "texture3d", - "layout": "channels", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "conv2d pw_correctness [1, 32, 16, 8, 8, False] float32 auto", - "operator": "aten.convolution.default", - "route": "auto", - "target_ms": 100, - "warmup_runs": 1, - "benchmark_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 0.001, - "rtol": 0.001 - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 32, 8, 8], - "dtype": "float32", - "storage": "texture3d", - "layout": "channels", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "weight", - "sizes": [16, 32, 1, 1], - "dtype": "float32", - "storage": "constant", - "layout": "channels", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - null, - { - "int_list": [1, 1] - }, - { - "int_list": [0, 0] - }, - { - "int_list": [1, 1] - }, - { - "bool": false - }, - { - "int_list": [0, 0] - }, - { - "int": 1 - }, - { - "tensor": { - "name": "output", - "sizes": [1, 16, 8, 8], - "dtype": "float32", - "storage": "texture3d", - "layout": "channels", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "conv2d pw_correctness [1, 16, 32, 8, 8, True] float32 auto", - "operator": "aten.convolution.default", - "route": "auto", - "target_ms": 100, - "warmup_runs": 1, - "benchmark_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 0.001, - "rtol": 0.001 - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 16, 8, 8], - "dtype": "float32", - "storage": "texture3d", - "layout": "channels", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "weight", - "sizes": [32, 16, 1, 1], - "dtype": "float32", - "storage": "constant", - "layout": "channels", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "bias", - "sizes": [32], - "dtype": "float32", - "storage": "constant", - "layout": "channels", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "int_list": [1, 1] - }, - { - "int_list": [0, 0] - }, - { - "int_list": [1, 1] - }, - { - "bool": false - }, - { - "int_list": [0, 0] - }, - { - "int": 1 - }, - { - "tensor": { - "name": "output", - "sizes": [1, 32, 8, 8], - "dtype": "float32", - "storage": "texture3d", - "layout": "channels", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "conv2d pw_correctness [1, 13, 27, 8, 8, False] float32 auto", - "operator": "aten.convolution.default", - "route": "auto", - "target_ms": 100, - "warmup_runs": 1, - "benchmark_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 0.001, - "rtol": 0.001 - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 13, 8, 8], - "dtype": "float32", - "storage": "texture3d", - "layout": "channels", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "weight", - "sizes": [27, 13, 1, 1], - "dtype": "float32", - "storage": "constant", - "layout": "channels", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - null, - { - "int_list": [1, 1] - }, - { - "int_list": [0, 0] - }, - { - "int_list": [1, 1] - }, - { - "bool": false - }, - { - "int_list": [0, 0] - }, - { - "int": 1 - }, - { - "tensor": { - "name": "output", - "sizes": [1, 27, 8, 8], - "dtype": "float32", - "storage": "texture3d", - "layout": "channels", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "conv2d pw_correctness [1, 33, 17, 8, 8, False] float32 auto", - "operator": "aten.convolution.default", - "route": "auto", - "target_ms": 100, - "warmup_runs": 1, - "benchmark_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 0.001, - "rtol": 0.001 - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 33, 8, 8], - "dtype": "float32", - "storage": "texture3d", - "layout": "channels", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "weight", - "sizes": [17, 33, 1, 1], - "dtype": "float32", - "storage": "constant", - "layout": "channels", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - null, - { - "int_list": [1, 1] - }, - { - "int_list": [0, 0] - }, - { - "int_list": [1, 1] - }, - { - "bool": false - }, - { - "int_list": [0, 0] - }, - { - "int": 1 - }, - { - "tensor": { - "name": "output", - "sizes": [1, 17, 8, 8], - "dtype": "float32", - "storage": "texture3d", - "layout": "channels", - "data": { - "fill": 0 - } - } - } - ] - ] - } -] diff --git a/backends/vulkan/test/ops/benchmark_v2/operators/convolution/q8ta/cases/correctness.json b/backends/vulkan/test/ops/benchmark_v2/operators/convolution/q8ta/cases/correctness.json index 19f3961636d..eecdfe2fac8 100644 --- a/backends/vulkan/test/ops/benchmark_v2/operators/convolution/q8ta/cases/correctness.json +++ b/backends/vulkan/test/ops/benchmark_v2/operators/convolution/q8ta/cases/correctness.json @@ -1255,7 +1255,7 @@ }, "route": "v_ROUTE", "sweep_variables": { - "v_ROUTE": ["general", "im2col", "legacy_4w4c", "auto"] + "v_ROUTE": ["general", "im2col", "auto"] }, "args_sweep": [ [ @@ -2870,7 +2870,7 @@ }, "route": "v_ROUTE", "sweep_variables": { - "v_ROUTE": ["general", "im2col", "legacy_4w4c", "auto"] + "v_ROUTE": ["general", "im2col", "auto"] }, "args_sweep": [ [ @@ -3470,5 +3470,7438 @@ } ] ] + }, + { + "name": "q8ta/conv2d/im2col", + "operator": "et_vk.q8ta_conv2d.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.25, + "rtol": 0 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["im2col", "im2col_unsigned", "im2col_auto"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 4, 5, 5], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "repeat": [-128, -1, 0, 1, 127] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [5, 36], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [-1, 127, 0, -128, 1] + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [8], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [8], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0.00390625] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [8], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [3, 3] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 5, 3, 3], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/im2col (2)", + "operator": "et_vk.q8ta_conv2d.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.25, + "rtol": 0 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["im2col_unsigned"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 4, 1, 1], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [262145, 4], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_int": [-128, 127] + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [262148], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [262148], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0.00390625] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [262148], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 262145, 1, 1], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/im2col [19]", + "operator": "et_vk.q8ta_conv2d.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.25, + "rtol": 0 + }, + "route": "im2col_auto", + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 4, 91, 91], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [1, 33124], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_int": [-128, 127] + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [4], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [4], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0.00390625] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [4], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [91, 91] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 1, 1, 1], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/im2col (4)", + "operator": "et_vk.q8ta_conv2d.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.25, + "rtol": 0 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["im2col", "im2col_unsigned", "im2col_auto"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 4, 90, 91], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "repeat": [127] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 0 + }, + { + "tensor": { + "name": "weight", + "sizes": [1, 32760], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [127] + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [4], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [4], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [9.999999974752427e-7] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [4], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [90, 91] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 1, 1, 1], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/im2col (5)", + "operator": "et_vk.q8ta_conv2d.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.25, + "rtol": 0 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["im2col", "im2col_unsigned", "im2col_auto"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 8, 6, 7], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "repeat": [-128, -1, 0, 1, 127] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 11 + }, + { + "tensor": { + "name": "weight", + "sizes": [8, 36], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [-1, 127, 0, -128, 1] + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [8], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [8], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0.00390625] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -3 + }, + { + "tensor": { + "name": "bias", + "sizes": [8], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [3, 3] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int": 2 + }, + { + "string": "relu" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 8, 6, 7], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/im2col (6)", + "operator": "et_vk.q8ta_conv2d.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.25, + "rtol": 0 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["im2col", "im2col_unsigned", "im2col_auto"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 8, 7, 7], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "repeat": [-128, -1, 0, 1, 127] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 127 + }, + { + "tensor": { + "name": "weight", + "sizes": [12, 72], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [-1, 127, 0, -128, 1] + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [12], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [12], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0.00390625] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -5 + }, + { + "tensor": { + "name": "bias", + "sizes": [12], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [3, 3] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "relu" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 12, 4, 4], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/im2col (7)", + "operator": "et_vk.q8ta_conv2d.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.25, + "rtol": 0 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["im2col", "im2col_unsigned", "im2col_auto"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 8, 5, 5], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "repeat": [-128, -1, 0, 1, 127] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": -7 + }, + { + "tensor": { + "name": "weight", + "sizes": [8, 72], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [-1, 127, 0, -128, 1] + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [8], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [8], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0.00390625] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": 3 + }, + null, + { + "int_list": [3, 3] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 8, 5, 5], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/im2col (8)", + "operator": "et_vk.q8ta_conv2d.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.25, + "rtol": 0 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["im2col", "im2col_unsigned", "im2col_auto"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 8, 9, 9], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "repeat": [-128, -1, 0, 1, 127] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": -128 + }, + { + "tensor": { + "name": "weight", + "sizes": [12, 72], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [-1, 127, 0, -128, 1] + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [12], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [12], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0.00390625] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": 5 + }, + null, + { + "int_list": [3, 3] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [2, 2] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 12, 9, 9], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/narrow_workgroup [0]", + "operator": "et_vk.q8ta_conv2d.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "general", + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 32, 9, 9], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4c", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 288], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_int": [-128, 127] + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [64], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [64], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [64], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [3, 3] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 9, 9], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/narrow_workgroup [1]", + "operator": "et_vk.q8ta_conv2d.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "general", + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 32, 7, 7], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4c", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [128, 288], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_int": [-128, 127] + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [128], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [128], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [128], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [3, 3] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 128, 7, 7], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/streaming_im2col [0]", + "operator": "et_vk.q8ta_conv2d.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "im2col_fallback", + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [10, 32, 30, 99], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-8, 12] + } + } + }, + { + "float": 1.0 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [4, 288], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_int": [-128, 127] + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [4], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [4], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [4], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [3, 3] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [10, 4, 30, 99], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/streaming_im2col [1]", + "operator": "et_vk.q8ta_conv2d.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "im2col_fallback", + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [10, 64, 30, 99], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-8, 12] + } + } + }, + { + "float": 1.0 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [4, 576], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_int": [-128, 127] + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [4], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [4], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [4], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [3, 3] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [10, 4, 30, 99], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/streaming_im2col", + "operator": "et_vk.q8ta_conv2d.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "im2col_auto", + "sweep_variables": { + "v_LAYOUT": ["4c1w", "4w4c"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [10, 32, 30, 99], + "dtype": "int8x4", + "storage": "buffer", + "layout": "v_LAYOUT", + "data": { + "rand_uniform_int": [-8, 12] + } + } + }, + { + "float": 1.0 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [4, 288], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_int": [-128, 127] + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [4], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [4], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [4], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [3, 3] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [10, 4, 30, 99], + "dtype": "int8x4", + "storage": "buffer", + "layout": "v_LAYOUT", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/streaming_im2col [4]", + "operator": "et_vk.q8ta_conv2d.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "im2col_auto", + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [10, 64, 30, 99], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4c1w", + "data": { + "rand_uniform_int": [-8, 12] + } + } + }, + { + "float": 1.0 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [4, 576], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_int": [-128, 127] + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [4], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [4], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [4], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [3, 3] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [10, 4, 30, 99], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4c1w", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/streaming_im2col [6]", + "operator": "et_vk.q8ta_conv2d.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "im2col_auto", + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [20, 32, 30, 99], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-8, 12] + } + } + }, + { + "float": 1.0 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [10, 288], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_int": [-128, 127] + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [12], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [12], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [12], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [3, 3] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [20, 10, 30, 99], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/streaming_im2col [5]", + "operator": "et_vk.q8ta_conv2d.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "im2col_auto", + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [20, 32, 30, 99], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-8, 12] + } + } + }, + { + "float": 1.0 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [16, 144], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_int": [-128, 127] + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [16], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [16], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [16], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [3, 3] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int": 2 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [20, 16, 30, 99], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/streaming_dynamic_shrink", + "check": "q8ta_streaming_dynamic_shrink", + "parameters": { + "operator_case": { + "name": "q8ta streaming dynamic shrink operator", + "operator": "et_vk.q8ta_conv2d.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "im2col_auto", + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [10, 64, 30, 99], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-8, 12] + } + } + }, + { + "float": 1.0 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [4, 576], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_int": [-128, 127] + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [4], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [4], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [ + 0.004999999888241291, 0.014999999664723873 + ] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [4], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [3, 3] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [10, 4, 30, 99], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + "input_name": "input", + "output_name": "output", + "stream_plan": { + "batch": 10, + "flattened_kernel_size": 576, + "out_height": 30, + "out_width": 99, + "scratch_budget_bytes": 16777216 + }, + "expected_plan": { + "num_tiles": 2, + "min_rows_per_tile": 21 + }, + "resized_width": 51 + }, + "validation_steps": [ + { + "tensor_sizes": { + "input": [10, 64, 30, 99] + }, + "expected_output_sizes": [10, 4, 30, 99], + "validate_output": false + }, + { + "tensor_sizes": { + "input": [1, 64, 20, 51] + }, + "expected_output_sizes": [1, 4, 20, 51], + "validate_output": true + }, + { + "tensor_sizes": { + "input": [10, 64, 30, 99] + }, + "expected_output_sizes": [10, 4, 30, 99], + "validate_output": false + }, + { + "tensor_sizes": { + "input": [1, 64, 20, 51] + }, + "expected_output_sizes": [1, 4, 20, 51], + "validate_output": true + }, + { + "tensor_sizes": { + "input": [10, 64, 30, 99] + }, + "expected_output_sizes": [10, 4, 30, 99], + "validate_output": false + }, + { + "tensor_sizes": { + "input": [1, 64, 20, 51] + }, + "expected_output_sizes": [1, 4, 20, 51], + "validate_output": true + }, + { + "tensor_sizes": { + "input": [10, 64, 30, 99] + }, + "expected_output_sizes": [10, 4, 30, 99], + "validate_output": false + }, + { + "tensor_sizes": { + "input": [1, 64, 20, 51] + }, + "expected_output_sizes": [1, 4, 20, 51], + "validate_output": true + } + ] + }, + { + "name": "q8ta/conv2d/unsigned_dot_selection", + "check": "q8ta_unsigned_dot_selection", + "parameters": { + "accumulator_limit_bytes": 33025, + "test_kernel_sizes": [33025, 33026] + } + }, + { + "name": "q8ta/conv2d/pw_correctness", + "operator": "et_vk.q8ta_conv2d_pw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 16, 8, 8], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [1, 16], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "pad_columns": { + "source": { + "rand_uniform_int": [-128, 127] + }, + "valid_columns": 16 + } + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [4], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [4], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [4], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 1, 8, 8], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4c1w", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/pw_correctness (2)", + "operator": "et_vk.q8ta_conv2d_pw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 16, 8, 8], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [2, 16], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "pad_columns": { + "source": { + "rand_uniform_int": [-128, 127] + }, + "valid_columns": 16 + } + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [4], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [4], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [4], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 2, 8, 8], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4c1w", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/pw_correctness (3)", + "operator": "et_vk.q8ta_conv2d_pw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 16, 8, 8], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [3, 16], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "pad_columns": { + "source": { + "rand_uniform_int": [-128, 127] + }, + "valid_columns": 16 + } + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [4], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [4], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [4], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 3, 8, 8], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4c1w", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/pw_correctness (4)", + "operator": "et_vk.q8ta_conv2d_pw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto", "legacy_4w4c"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 16, 8, 8], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [1, 16], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "pad_columns": { + "source": { + "rand_uniform_int": [-128, 127] + }, + "valid_columns": 16 + } + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [4], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [4], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [4], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 1, 8, 8], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/pw_correctness (5)", + "operator": "et_vk.q8ta_conv2d_pw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto", "legacy_4w4c"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 16, 8, 8], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [2, 16], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "pad_columns": { + "source": { + "rand_uniform_int": [-128, 127] + }, + "valid_columns": 16 + } + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [4], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [4], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [4], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 2, 8, 8], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/pw_correctness (6)", + "operator": "et_vk.q8ta_conv2d_pw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto", "legacy_4w4c"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 16, 8, 8], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [3, 16], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "pad_columns": { + "source": { + "rand_uniform_int": [-128, 127] + }, + "valid_columns": 16 + } + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [4], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [4], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [4], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 3, 8, 8], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/pw_correctness (7)", + "operator": "et_vk.q8ta_conv2d_pw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 16, 8, 8], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [1, 16], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "pad_columns": { + "source": { + "rand_uniform_int": [-128, 127] + }, + "valid_columns": 16 + } + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [4], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [4], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [4], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 1, 8, 8], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/pw_correctness (8)", + "operator": "et_vk.q8ta_conv2d_pw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 16, 8, 8], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [2, 16], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "pad_columns": { + "source": { + "rand_uniform_int": [-128, 127] + }, + "valid_columns": 16 + } + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [4], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [4], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [4], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 2, 8, 8], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/pw_correctness (9)", + "operator": "et_vk.q8ta_conv2d_pw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 16, 8, 8], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [3, 16], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "pad_columns": { + "source": { + "rand_uniform_int": [-128, 127] + }, + "valid_columns": 16 + } + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [4], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [4], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [4], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 3, 8, 8], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/pw_correctness (10)", + "operator": "et_vk.q8ta_conv2d_pw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 3, 64, 64], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [32, 4], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "pad_columns": { + "source": { + "pad_columns": { + "source": { + "rand_uniform_int": [-128, 127] + }, + "valid_columns": 3 + } + }, + "valid_columns": 3 + } + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [32], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [32], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [32], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 32, 64, 64], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4c1w", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/pw_correctness (11)", + "operator": "et_vk.q8ta_conv2d_pw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto", "legacy_4w4c"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 3, 64, 64], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [32, 4], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "pad_columns": { + "source": { + "pad_columns": { + "source": { + "rand_uniform_int": [-128, 127] + }, + "valid_columns": 3 + } + }, + "valid_columns": 3 + } + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [32], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [32], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [32], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 32, 64, 64], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/pw_correctness (12)", + "operator": "et_vk.q8ta_conv2d_pw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 3, 64, 64], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [32, 4], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "pad_columns": { + "source": { + "pad_columns": { + "source": { + "rand_uniform_int": [-128, 127] + }, + "valid_columns": 3 + } + }, + "valid_columns": 3 + } + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [32], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [32], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [32], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 32, 64, 64], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/pw_correctness (13)", + "operator": "et_vk.q8ta_conv2d_pw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 32, 32, 32], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 32], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "pad_columns": { + "source": { + "rand_uniform_int": [-128, 127] + }, + "valid_columns": 32 + } + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [64], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [64], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [64], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 32, 32], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4c1w", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/pw_correctness (14)", + "operator": "et_vk.q8ta_conv2d_pw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto", "legacy_4w4c"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 32, 32, 32], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 32], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "pad_columns": { + "source": { + "rand_uniform_int": [-128, 127] + }, + "valid_columns": 32 + } + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [64], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [64], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [64], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 32, 32], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/pw_correctness (15)", + "operator": "et_vk.q8ta_conv2d_pw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 32, 32, 32], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 32], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "pad_columns": { + "source": { + "rand_uniform_int": [-128, 127] + }, + "valid_columns": 32 + } + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [64], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [64], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [64], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 32, 32], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/pw_correctness (16)", + "operator": "et_vk.q8ta_conv2d_pw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 64, 16, 16], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [96, 64], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "pad_columns": { + "source": { + "rand_uniform_int": [-128, 127] + }, + "valid_columns": 64 + } + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [96], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [96], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [96], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 96, 16, 16], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4c1w", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/pw_correctness (17)", + "operator": "et_vk.q8ta_conv2d_pw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto", "legacy_4w4c"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 64, 16, 16], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [96, 64], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "pad_columns": { + "source": { + "rand_uniform_int": [-128, 127] + }, + "valid_columns": 64 + } + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [96], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [96], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [96], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 96, 16, 16], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/pw_correctness (18)", + "operator": "et_vk.q8ta_conv2d_pw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 64, 16, 16], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [96, 64], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "pad_columns": { + "source": { + "rand_uniform_int": [-128, 127] + }, + "valid_columns": 64 + } + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [96], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [96], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [96], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 96, 16, 16], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/pw_correctness (19)", + "operator": "et_vk.q8ta_conv2d_pw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 7, 57, 33], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [13, 8], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "pad_columns": { + "source": { + "pad_columns": { + "source": { + "rand_uniform_int": [-128, 127] + }, + "valid_columns": 7 + } + }, + "valid_columns": 7 + } + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [16], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [16], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [16], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 13, 57, 33], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4c1w", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/pw_correctness (20)", + "operator": "et_vk.q8ta_conv2d_pw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto", "legacy_4w4c"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 7, 57, 33], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [13, 8], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "pad_columns": { + "source": { + "pad_columns": { + "source": { + "rand_uniform_int": [-128, 127] + }, + "valid_columns": 7 + } + }, + "valid_columns": 7 + } + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [16], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [16], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [16], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 13, 57, 33], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/pw_correctness (21)", + "operator": "et_vk.q8ta_conv2d_pw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 7, 57, 33], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [13, 8], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "pad_columns": { + "source": { + "pad_columns": { + "source": { + "rand_uniform_int": [-128, 127] + }, + "valid_columns": 7 + } + }, + "valid_columns": 7 + } + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [16], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [16], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [16], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 13, 57, 33], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/pw_correctness (22)", + "operator": "et_vk.q8ta_conv2d_pw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 40, 64, 64], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [80, 40], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "pad_columns": { + "source": { + "rand_uniform_int": [-128, 127] + }, + "valid_columns": 40 + } + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [80], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [80], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [80], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 80, 64, 64], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4c1w", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/pw_correctness (23)", + "operator": "et_vk.q8ta_conv2d_pw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto", "legacy_4w4c"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 40, 64, 64], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [80, 40], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "pad_columns": { + "source": { + "rand_uniform_int": [-128, 127] + }, + "valid_columns": 40 + } + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [80], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [80], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [80], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 80, 64, 64], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/pw_correctness (24)", + "operator": "et_vk.q8ta_conv2d_pw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 40, 64, 64], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [80, 40], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "pad_columns": { + "source": { + "rand_uniform_int": [-128, 127] + }, + "valid_columns": 40 + } + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [80], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [80], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [80], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 80, 64, 64], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/pw_correctness (25)", + "operator": "et_vk.q8ta_conv2d_pw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto"], + "v_OUTPUT_LAYOUT": ["4c1w", "4w4c"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [2, 8, 8, 8], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [8, 8], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "pad_columns": { + "source": { + "rand_uniform_int": [-128, 127] + }, + "valid_columns": 8 + } + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [8], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [8], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [8], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [2, 8, 8, 8], + "dtype": "int8x4", + "storage": "buffer", + "layout": "v_OUTPUT_LAYOUT", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/pw_correctness (26)", + "operator": "et_vk.q8ta_conv2d_pw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 7, 7, 5], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-128, -5] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": -128 + }, + { + "tensor": { + "name": "weight", + "sizes": [13, 8], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "pad_columns": { + "source": { + "pad_columns": { + "source": { + "rand_uniform_int": [-128, 127] + }, + "valid_columns": 7 + } + }, + "valid_columns": 7 + } + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [16], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [16], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 13, 7, 5], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/dw_correctness", + "operator": "et_vk.q8ta_conv2d_dw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "auto", + "sweep_variables": { + "v_LAYOUT": ["4c1w", "4w4c", "4c"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 32, 64, 64], + "dtype": "int8x4", + "storage": "buffer", + "layout": "v_LAYOUT", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [3, 3, 32], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_int": [-128, 127] + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [32], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "column_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [32], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [32], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "int_list": [3, 3] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int": 32 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 32, 64, 64], + "dtype": "int8x4", + "storage": "buffer", + "layout": "v_LAYOUT", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/dw_correctness (2)", + "operator": "et_vk.q8ta_conv2d_dw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "auto", + "sweep_variables": { + "v_LAYOUT": ["4c1w", "4w4c", "4c"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 80, 16, 16], + "dtype": "int8x4", + "storage": "buffer", + "layout": "v_LAYOUT", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [3, 3, 80], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_int": [-128, 127] + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [80], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "column_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [80], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [80], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "int_list": [3, 3] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int": 80 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 80, 16, 16], + "dtype": "int8x4", + "storage": "buffer", + "layout": "v_LAYOUT", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/dw_correctness (3)", + "operator": "et_vk.q8ta_conv2d_dw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "auto", + "sweep_variables": { + "v_LAYOUT": ["4c1w", "4w4c", "4c"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 16, 57, 33], + "dtype": "int8x4", + "storage": "buffer", + "layout": "v_LAYOUT", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [3, 3, 16], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_int": [-128, 127] + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [16], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "column_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [16], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [16], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "int_list": [3, 3] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int": 16 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 16, 57, 33], + "dtype": "int8x4", + "storage": "buffer", + "layout": "v_LAYOUT", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/dw_correctness (4)", + "operator": "et_vk.q8ta_conv2d_dw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "auto", + "sweep_variables": { + "v_LAYOUT": ["4c1w", "4w4c"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [2, 8, 8, 8], + "dtype": "int8x4", + "storage": "buffer", + "layout": "v_LAYOUT", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [3, 3, 8], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_int": [-128, 127] + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [8], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "column_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [8], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [8], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "int_list": [3, 3] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int": 8 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [2, 8, 8, 8], + "dtype": "int8x4", + "storage": "buffer", + "layout": "v_LAYOUT", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/dw_correctness (5)", + "operator": "et_vk.q8ta_conv2d_dw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "auto", + "sweep_variables": { + "v_LAYOUT": ["4c1w", "4w4c", "4c"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 32, 64, 64], + "dtype": "int8x4", + "storage": "buffer", + "layout": "v_LAYOUT", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [5, 5, 32], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_int": [-128, 127] + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [32], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "column_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [32], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [32], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "int_list": [5, 5] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "int": 32 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 32, 64, 64], + "dtype": "int8x4", + "storage": "buffer", + "layout": "v_LAYOUT", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/dw_correctness (6)", + "operator": "et_vk.q8ta_conv2d_dw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "auto", + "sweep_variables": { + "v_LAYOUT": ["4c1w", "4w4c", "4c"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 64, 32, 32], + "dtype": "int8x4", + "storage": "buffer", + "layout": "v_LAYOUT", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [3, 3, 64], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_int": [-128, 127] + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [64], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "column_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [64], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [64], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "int_list": [3, 3] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "int": 64 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 17, 17], + "dtype": "int8x4", + "storage": "buffer", + "layout": "v_LAYOUT", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/dw_correctness (7)", + "operator": "et_vk.q8ta_conv2d_dw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "auto", + "sweep_variables": { + "v_LAYOUT": ["4c1w", "4w4c", "4c"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 96, 64, 64], + "dtype": "int8x4", + "storage": "buffer", + "layout": "v_LAYOUT", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [5, 5, 96], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_int": [-128, 127] + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [96], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "column_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [96], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [96], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "int_list": [5, 5] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "int": 96 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 96, 32, 32], + "dtype": "int8x4", + "storage": "buffer", + "layout": "v_LAYOUT", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/dw_correctness (8)", + "operator": "et_vk.q8ta_conv2d_dw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "auto", + "sweep_variables": { + "v_LAYOUT": ["4c1w", "4w4c", "4c"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 64, 32, 32], + "dtype": "int8x4", + "storage": "buffer", + "layout": "v_LAYOUT", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [3, 3, 64], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_int": [-128, 127] + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [64], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "column_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [64], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [64], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "int_list": [3, 3] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int": 64 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 16, 16], + "dtype": "int8x4", + "storage": "buffer", + "layout": "v_LAYOUT", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/dw_correctness [2]", + "operator": "et_vk.q8ta_conv2d_dw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "legacy_4w4c", + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 32, 64, 64], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [3, 3, 32], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_int": [-128, 127] + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [32], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "column_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [32], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [32], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "int_list": [3, 3] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int": 32 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 32, 64, 64], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/dw_correctness [14]", + "operator": "et_vk.q8ta_conv2d_dw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "legacy_4w4c", + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 80, 16, 16], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [3, 3, 80], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_int": [-128, 127] + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [80], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "column_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [80], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [80], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "int_list": [3, 3] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int": 80 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 80, 16, 16], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/dw_correctness [22]", + "operator": "et_vk.q8ta_conv2d_dw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "legacy_4w4c", + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 32, 64, 64], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [5, 5, 32], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_int": [-128, 127] + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [32], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "column_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [32], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [32], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "int_list": [5, 5] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "int": 32 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 32, 64, 64], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/dw_correctness [6]", + "operator": "et_vk.q8ta_conv2d_dw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "legacy_4w4c", + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 64, 32, 32], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [3, 3, 64], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_int": [-128, 127] + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [64], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "column_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [64], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [64], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "int_list": [3, 3] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "int": 64 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 17, 17], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/dw_correctness [26]", + "operator": "et_vk.q8ta_conv2d_dw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "legacy_4w4c", + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 96, 64, 64], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [5, 5, 96], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_int": [-128, 127] + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [96], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "column_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [96], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [96], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "int_list": [5, 5] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "int": 96 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 96, 32, 32], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/dw_correctness [10]", + "operator": "et_vk.q8ta_conv2d_dw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "legacy_4w4c", + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 64, 32, 32], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [3, 3, 64], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_int": [-128, 127] + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [64], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "column_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [64], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [64], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "int_list": [3, 3] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int": 64 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 16, 16], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/dw_narrow_workgroup [0]", + "operator": "et_vk.q8ta_conv2d_dw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "auto", + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 128, 7, 7], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4c", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [3, 3, 128], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_int": [-128, 127] + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [128], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "column_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [128], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [128], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "int_list": [3, 3] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int": 128 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 128, 7, 7], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/dw_narrow_workgroup", + "operator": "et_vk.q8ta_conv2d_dw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "auto", + "sweep_variables": { + "v_SIZES": [ + [1, 64, 9, 9], + [1, 64, 13, 13] + ] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": "v_SIZES", + "dtype": "int8x4", + "storage": "buffer", + "layout": "4c", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [3, 3, 64], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_int": [-128, 127] + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [64], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "column_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [64], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [64], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "int_list": [3, 3] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int": 64 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": "v_SIZES", + "dtype": "int8x4", + "storage": "buffer", + "layout": "4c", + "data": { + "fill": 0 + } + } + } + ] + ] } ] diff --git a/backends/vulkan/test/ops/benchmark_v2/operators/convolution/q8ta/cases/dw_correctness.json b/backends/vulkan/test/ops/benchmark_v2/operators/convolution/q8ta/cases/dw_correctness.json deleted file mode 100644 index 65e007ae390..00000000000 --- a/backends/vulkan/test/ops/benchmark_v2/operators/convolution/q8ta/cases/dw_correctness.json +++ /dev/null @@ -1,1826 +0,0 @@ -[ - { - "name": "q8ta/conv2d/dw_correctness", - "operator": "et_vk.q8ta_conv2d_dw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "auto", - "sweep_variables": { - "v_LAYOUT": ["4c1w", "4w4c", "4c"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 32, 64, 64], - "dtype": "int8x4", - "storage": "buffer", - "layout": "v_LAYOUT", - "data": { - "rand_uniform_int": [-121, 125] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": 2 - }, - { - "tensor": { - "name": "weight", - "sizes": [3, 3, 32], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_int": [-128, 127] - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [32], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "column_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [32], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - { - "tensor": { - "name": "bias", - "sizes": [32], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "int_list": [3, 3] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int": 32 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [1, 32, 64, 64], - "dtype": "int8x4", - "storage": "buffer", - "layout": "v_LAYOUT", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "q8ta/conv2d/dw_correctness (2)", - "operator": "et_vk.q8ta_conv2d_dw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "auto", - "sweep_variables": { - "v_LAYOUT": ["4c1w", "4w4c", "4c"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 80, 16, 16], - "dtype": "int8x4", - "storage": "buffer", - "layout": "v_LAYOUT", - "data": { - "rand_uniform_int": [-121, 125] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": 2 - }, - { - "tensor": { - "name": "weight", - "sizes": [3, 3, 80], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_int": [-128, 127] - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [80], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "column_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [80], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - { - "tensor": { - "name": "bias", - "sizes": [80], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "int_list": [3, 3] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int": 80 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [1, 80, 16, 16], - "dtype": "int8x4", - "storage": "buffer", - "layout": "v_LAYOUT", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "q8ta/conv2d/dw_correctness (3)", - "operator": "et_vk.q8ta_conv2d_dw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "auto", - "sweep_variables": { - "v_LAYOUT": ["4c1w", "4w4c", "4c"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 16, 57, 33], - "dtype": "int8x4", - "storage": "buffer", - "layout": "v_LAYOUT", - "data": { - "rand_uniform_int": [-121, 125] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": 2 - }, - { - "tensor": { - "name": "weight", - "sizes": [3, 3, 16], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_int": [-128, 127] - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [16], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "column_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [16], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - { - "tensor": { - "name": "bias", - "sizes": [16], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "int_list": [3, 3] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int": 16 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [1, 16, 57, 33], - "dtype": "int8x4", - "storage": "buffer", - "layout": "v_LAYOUT", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "q8ta/conv2d/dw_correctness (4)", - "operator": "et_vk.q8ta_conv2d_dw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "auto", - "sweep_variables": { - "v_LAYOUT": ["4c1w", "4w4c"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [2, 8, 8, 8], - "dtype": "int8x4", - "storage": "buffer", - "layout": "v_LAYOUT", - "data": { - "rand_uniform_int": [-121, 125] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": 2 - }, - { - "tensor": { - "name": "weight", - "sizes": [3, 3, 8], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_int": [-128, 127] - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [8], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "column_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [8], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - { - "tensor": { - "name": "bias", - "sizes": [8], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "int_list": [3, 3] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int": 8 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [2, 8, 8, 8], - "dtype": "int8x4", - "storage": "buffer", - "layout": "v_LAYOUT", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "q8ta/conv2d/dw_correctness (5)", - "operator": "et_vk.q8ta_conv2d_dw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "auto", - "sweep_variables": { - "v_LAYOUT": ["4c1w", "4w4c", "4c"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 32, 64, 64], - "dtype": "int8x4", - "storage": "buffer", - "layout": "v_LAYOUT", - "data": { - "rand_uniform_int": [-121, 125] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": 2 - }, - { - "tensor": { - "name": "weight", - "sizes": [5, 5, 32], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_int": [-128, 127] - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [32], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "column_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [32], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - { - "tensor": { - "name": "bias", - "sizes": [32], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "int_list": [5, 5] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [2, 2] - }, - { - "int_list": [1, 1] - }, - { - "int": 32 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [1, 32, 64, 64], - "dtype": "int8x4", - "storage": "buffer", - "layout": "v_LAYOUT", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "q8ta/conv2d/dw_correctness (6)", - "operator": "et_vk.q8ta_conv2d_dw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "auto", - "sweep_variables": { - "v_LAYOUT": ["4c1w", "4w4c", "4c"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 64, 32, 32], - "dtype": "int8x4", - "storage": "buffer", - "layout": "v_LAYOUT", - "data": { - "rand_uniform_int": [-121, 125] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": 2 - }, - { - "tensor": { - "name": "weight", - "sizes": [3, 3, 64], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_int": [-128, 127] - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [64], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "column_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [64], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - { - "tensor": { - "name": "bias", - "sizes": [64], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "int_list": [3, 3] - }, - { - "int_list": [2, 2] - }, - { - "int_list": [2, 2] - }, - { - "int_list": [1, 1] - }, - { - "int": 64 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [1, 64, 17, 17], - "dtype": "int8x4", - "storage": "buffer", - "layout": "v_LAYOUT", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "q8ta/conv2d/dw_correctness (7)", - "operator": "et_vk.q8ta_conv2d_dw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "auto", - "sweep_variables": { - "v_LAYOUT": ["4c1w", "4w4c", "4c"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 96, 64, 64], - "dtype": "int8x4", - "storage": "buffer", - "layout": "v_LAYOUT", - "data": { - "rand_uniform_int": [-121, 125] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": 2 - }, - { - "tensor": { - "name": "weight", - "sizes": [5, 5, 96], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_int": [-128, 127] - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [96], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "column_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [96], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - { - "tensor": { - "name": "bias", - "sizes": [96], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "int_list": [5, 5] - }, - { - "int_list": [2, 2] - }, - { - "int_list": [2, 2] - }, - { - "int_list": [1, 1] - }, - { - "int": 96 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [1, 96, 32, 32], - "dtype": "int8x4", - "storage": "buffer", - "layout": "v_LAYOUT", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "q8ta/conv2d/dw_correctness (8)", - "operator": "et_vk.q8ta_conv2d_dw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "auto", - "sweep_variables": { - "v_LAYOUT": ["4c1w", "4w4c", "4c"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 64, 32, 32], - "dtype": "int8x4", - "storage": "buffer", - "layout": "v_LAYOUT", - "data": { - "rand_uniform_int": [-121, 125] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": 2 - }, - { - "tensor": { - "name": "weight", - "sizes": [3, 3, 64], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_int": [-128, 127] - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [64], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "column_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [64], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - { - "tensor": { - "name": "bias", - "sizes": [64], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "int_list": [3, 3] - }, - { - "int_list": [2, 2] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int": 64 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [1, 64, 16, 16], - "dtype": "int8x4", - "storage": "buffer", - "layout": "v_LAYOUT", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "q8ta/conv2d/dw_correctness [2]", - "operator": "et_vk.q8ta_conv2d_dw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "legacy_4w4c", - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 32, 64, 64], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "rand_uniform_int": [-121, 125] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": 2 - }, - { - "tensor": { - "name": "weight", - "sizes": [3, 3, 32], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_int": [-128, 127] - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [32], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "column_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [32], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - { - "tensor": { - "name": "bias", - "sizes": [32], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "int_list": [3, 3] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int": 32 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [1, 32, 64, 64], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "q8ta/conv2d/dw_correctness [14]", - "operator": "et_vk.q8ta_conv2d_dw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "legacy_4w4c", - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 80, 16, 16], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "rand_uniform_int": [-121, 125] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": 2 - }, - { - "tensor": { - "name": "weight", - "sizes": [3, 3, 80], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_int": [-128, 127] - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [80], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "column_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [80], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - { - "tensor": { - "name": "bias", - "sizes": [80], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "int_list": [3, 3] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int": 80 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [1, 80, 16, 16], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "q8ta/conv2d/dw_correctness [18]", - "operator": "et_vk.q8ta_conv2d_dw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "legacy_4w4c", - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 16, 57, 33], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "rand_uniform_int": [-121, 125] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": 2 - }, - { - "tensor": { - "name": "weight", - "sizes": [3, 3, 16], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_int": [-128, 127] - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [16], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "column_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [16], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - { - "tensor": { - "name": "bias", - "sizes": [16], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "int_list": [3, 3] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int": 16 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [1, 16, 57, 33], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "q8ta/conv2d/dw_correctness [22]", - "operator": "et_vk.q8ta_conv2d_dw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "legacy_4w4c", - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 32, 64, 64], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "rand_uniform_int": [-121, 125] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": 2 - }, - { - "tensor": { - "name": "weight", - "sizes": [5, 5, 32], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_int": [-128, 127] - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [32], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "column_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [32], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - { - "tensor": { - "name": "bias", - "sizes": [32], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "int_list": [5, 5] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [2, 2] - }, - { - "int_list": [1, 1] - }, - { - "int": 32 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [1, 32, 64, 64], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "q8ta/conv2d/dw_correctness [6]", - "operator": "et_vk.q8ta_conv2d_dw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "legacy_4w4c", - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 64, 32, 32], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "rand_uniform_int": [-121, 125] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": 2 - }, - { - "tensor": { - "name": "weight", - "sizes": [3, 3, 64], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_int": [-128, 127] - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [64], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "column_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [64], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - { - "tensor": { - "name": "bias", - "sizes": [64], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "int_list": [3, 3] - }, - { - "int_list": [2, 2] - }, - { - "int_list": [2, 2] - }, - { - "int_list": [1, 1] - }, - { - "int": 64 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [1, 64, 17, 17], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "q8ta/conv2d/dw_correctness [26]", - "operator": "et_vk.q8ta_conv2d_dw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "legacy_4w4c", - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 96, 64, 64], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "rand_uniform_int": [-121, 125] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": 2 - }, - { - "tensor": { - "name": "weight", - "sizes": [5, 5, 96], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_int": [-128, 127] - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [96], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "column_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [96], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - { - "tensor": { - "name": "bias", - "sizes": [96], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "int_list": [5, 5] - }, - { - "int_list": [2, 2] - }, - { - "int_list": [2, 2] - }, - { - "int_list": [1, 1] - }, - { - "int": 96 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [1, 96, 32, 32], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "q8ta/conv2d/dw_correctness [10]", - "operator": "et_vk.q8ta_conv2d_dw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "legacy_4w4c", - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 64, 32, 32], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "rand_uniform_int": [-121, 125] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": 2 - }, - { - "tensor": { - "name": "weight", - "sizes": [3, 3, 64], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_int": [-128, 127] - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [64], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "column_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [64], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - { - "tensor": { - "name": "bias", - "sizes": [64], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "int_list": [3, 3] - }, - { - "int_list": [2, 2] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int": 64 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [1, 64, 16, 16], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "fill": 0 - } - } - } - ] - ] - } -] diff --git a/backends/vulkan/test/ops/benchmark_v2/operators/convolution/q8ta/cases/legacy_4w4c_padding.json b/backends/vulkan/test/ops/benchmark_v2/operators/convolution/q8ta/cases/legacy_4w4c_padding.json new file mode 100644 index 00000000000..5150eab8896 --- /dev/null +++ b/backends/vulkan/test/ops/benchmark_v2/operators/convolution/q8ta/cases/legacy_4w4c_padding.json @@ -0,0 +1,368 @@ +[ + { + "name": "q8ta/conv2d/correctness int8x4 buffer 4w4c (10)", + "operator": "et_vk.q8ta_conv2d.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["legacy_4w4c"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 32, 77, 77], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [16, 288], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_int": [-128, 127] + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [16], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [16], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [16], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [3, 3] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int": 1 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 16, 77, 77], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/correctness int8x4 buffer 4w4c (23)", + "operator": "et_vk.q8ta_conv2d.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["legacy_4w4c"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 96, 81, 81], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [96, 288], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_int": [-128, 127] + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [96], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "row_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [96], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [96], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "repeat": [0] + } + } + }, + { + "int_list": [3, 3] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int": 3 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 96, 41, 41], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/conv2d/dw_correctness [18]", + "operator": "et_vk.q8ta_conv2d_dw.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0018818534756524, + "rtol": 0 + }, + "route": "legacy_4w4c", + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 16, 57, 33], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "rand_uniform_int": [-121, 125] + } + } + }, + { + "float": 0.008123000152409077 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "weight", + "sizes": [3, 3, 16], + "dtype": "int8", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_int": [-128, 127] + } + } + }, + { + "tensor": { + "name": "weight_sums", + "sizes": [16], + "dtype": "int32", + "storage": "constant", + "layout": "width", + "data": { + "column_sum": "weight" + } + } + }, + { + "tensor": { + "name": "weight_scales", + "sizes": [16], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] + } + } + }, + { + "float": 0.05313999950885773 + }, + { + "int": -1 + }, + { + "tensor": { + "name": "bias", + "sizes": [16], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "int_list": [3, 3] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int": 16 + }, + { + "string": "none" + }, + { + "tensor": { + "name": "output", + "sizes": [1, 16, 57, 33], + "dtype": "int8x4", + "storage": "buffer", + "layout": "4w4c", + "data": { + "fill": 0 + } + } + } + ] + ] + } +] diff --git a/backends/vulkan/test/ops/benchmark_v2/operators/convolution/q8ta/cases/pw_correctness.json b/backends/vulkan/test/ops/benchmark_v2/operators/convolution/q8ta/cases/pw_correctness.json deleted file mode 100644 index 19b3062de7b..00000000000 --- a/backends/vulkan/test/ops/benchmark_v2/operators/convolution/q8ta/cases/pw_correctness.json +++ /dev/null @@ -1,3355 +0,0 @@ -[ - { - "name": "q8ta/conv2d/pw_correctness", - "operator": "et_vk.q8ta_conv2d_pw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "v_ROUTE", - "sweep_variables": { - "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 16, 8, 8], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "rand_uniform_int": [-121, 125] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": 2 - }, - { - "tensor": { - "name": "weight", - "sizes": [1, 16], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "pad_columns": { - "source": { - "rand_uniform_int": [-128, 127] - }, - "valid_columns": 16 - } - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [4], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "row_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [4], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - { - "tensor": { - "name": "bias", - "sizes": [4], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "repeat": [0] - } - } - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [0, 0] - }, - { - "int_list": [1, 1] - }, - { - "int": 1 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [1, 1, 8, 8], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4c1w", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "q8ta/conv2d/pw_correctness (2)", - "operator": "et_vk.q8ta_conv2d_pw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "v_ROUTE", - "sweep_variables": { - "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 16, 8, 8], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "rand_uniform_int": [-121, 125] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": 2 - }, - { - "tensor": { - "name": "weight", - "sizes": [2, 16], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "pad_columns": { - "source": { - "rand_uniform_int": [-128, 127] - }, - "valid_columns": 16 - } - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [4], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "row_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [4], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - { - "tensor": { - "name": "bias", - "sizes": [4], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "repeat": [0] - } - } - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [0, 0] - }, - { - "int_list": [1, 1] - }, - { - "int": 1 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [1, 2, 8, 8], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4c1w", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "q8ta/conv2d/pw_correctness (3)", - "operator": "et_vk.q8ta_conv2d_pw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "v_ROUTE", - "sweep_variables": { - "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 16, 8, 8], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "rand_uniform_int": [-121, 125] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": 2 - }, - { - "tensor": { - "name": "weight", - "sizes": [3, 16], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "pad_columns": { - "source": { - "rand_uniform_int": [-128, 127] - }, - "valid_columns": 16 - } - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [4], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "row_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [4], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - { - "tensor": { - "name": "bias", - "sizes": [4], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "repeat": [0] - } - } - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [0, 0] - }, - { - "int_list": [1, 1] - }, - { - "int": 1 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [1, 3, 8, 8], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4c1w", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "q8ta/conv2d/pw_correctness (4)", - "operator": "et_vk.q8ta_conv2d_pw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "v_ROUTE", - "sweep_variables": { - "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto", "legacy_4w4c"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 16, 8, 8], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "rand_uniform_int": [-121, 125] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": 2 - }, - { - "tensor": { - "name": "weight", - "sizes": [1, 16], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "pad_columns": { - "source": { - "rand_uniform_int": [-128, 127] - }, - "valid_columns": 16 - } - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [4], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "row_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [4], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - { - "tensor": { - "name": "bias", - "sizes": [4], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "repeat": [0] - } - } - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [0, 0] - }, - { - "int_list": [1, 1] - }, - { - "int": 1 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [1, 1, 8, 8], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "q8ta/conv2d/pw_correctness (5)", - "operator": "et_vk.q8ta_conv2d_pw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "v_ROUTE", - "sweep_variables": { - "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto", "legacy_4w4c"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 16, 8, 8], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "rand_uniform_int": [-121, 125] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": 2 - }, - { - "tensor": { - "name": "weight", - "sizes": [2, 16], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "pad_columns": { - "source": { - "rand_uniform_int": [-128, 127] - }, - "valid_columns": 16 - } - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [4], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "row_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [4], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - { - "tensor": { - "name": "bias", - "sizes": [4], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "repeat": [0] - } - } - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [0, 0] - }, - { - "int_list": [1, 1] - }, - { - "int": 1 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [1, 2, 8, 8], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "q8ta/conv2d/pw_correctness (6)", - "operator": "et_vk.q8ta_conv2d_pw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "v_ROUTE", - "sweep_variables": { - "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto", "legacy_4w4c"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 16, 8, 8], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "rand_uniform_int": [-121, 125] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": 2 - }, - { - "tensor": { - "name": "weight", - "sizes": [3, 16], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "pad_columns": { - "source": { - "rand_uniform_int": [-128, 127] - }, - "valid_columns": 16 - } - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [4], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "row_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [4], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - { - "tensor": { - "name": "bias", - "sizes": [4], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "repeat": [0] - } - } - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [0, 0] - }, - { - "int_list": [1, 1] - }, - { - "int": 1 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [1, 3, 8, 8], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "q8ta/conv2d/pw_correctness (7)", - "operator": "et_vk.q8ta_conv2d_pw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "v_ROUTE", - "sweep_variables": { - "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 16, 8, 8], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "rand_uniform_int": [-121, 125] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": 2 - }, - { - "tensor": { - "name": "weight", - "sizes": [1, 16], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "pad_columns": { - "source": { - "rand_uniform_int": [-128, 127] - }, - "valid_columns": 16 - } - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [4], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "row_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [4], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - { - "tensor": { - "name": "bias", - "sizes": [4], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "repeat": [0] - } - } - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [0, 0] - }, - { - "int_list": [1, 1] - }, - { - "int": 1 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [1, 1, 8, 8], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4c", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "q8ta/conv2d/pw_correctness (8)", - "operator": "et_vk.q8ta_conv2d_pw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "v_ROUTE", - "sweep_variables": { - "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 16, 8, 8], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "rand_uniform_int": [-121, 125] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": 2 - }, - { - "tensor": { - "name": "weight", - "sizes": [2, 16], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "pad_columns": { - "source": { - "rand_uniform_int": [-128, 127] - }, - "valid_columns": 16 - } - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [4], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "row_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [4], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - { - "tensor": { - "name": "bias", - "sizes": [4], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "repeat": [0] - } - } - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [0, 0] - }, - { - "int_list": [1, 1] - }, - { - "int": 1 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [1, 2, 8, 8], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4c", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "q8ta/conv2d/pw_correctness (9)", - "operator": "et_vk.q8ta_conv2d_pw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "v_ROUTE", - "sweep_variables": { - "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 16, 8, 8], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "rand_uniform_int": [-121, 125] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": 2 - }, - { - "tensor": { - "name": "weight", - "sizes": [3, 16], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "pad_columns": { - "source": { - "rand_uniform_int": [-128, 127] - }, - "valid_columns": 16 - } - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [4], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "row_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [4], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - { - "tensor": { - "name": "bias", - "sizes": [4], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "repeat": [0] - } - } - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [0, 0] - }, - { - "int_list": [1, 1] - }, - { - "int": 1 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [1, 3, 8, 8], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4c", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "q8ta/conv2d/pw_correctness (10)", - "operator": "et_vk.q8ta_conv2d_pw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "v_ROUTE", - "sweep_variables": { - "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 3, 64, 64], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "rand_uniform_int": [-121, 125] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": 2 - }, - { - "tensor": { - "name": "weight", - "sizes": [32, 4], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "pad_columns": { - "source": { - "pad_columns": { - "source": { - "rand_uniform_int": [-128, 127] - }, - "valid_columns": 3 - } - }, - "valid_columns": 3 - } - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [32], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "row_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [32], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - { - "tensor": { - "name": "bias", - "sizes": [32], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "repeat": [0] - } - } - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [0, 0] - }, - { - "int_list": [1, 1] - }, - { - "int": 1 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [1, 32, 64, 64], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4c1w", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "q8ta/conv2d/pw_correctness (11)", - "operator": "et_vk.q8ta_conv2d_pw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "v_ROUTE", - "sweep_variables": { - "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto", "legacy_4w4c"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 3, 64, 64], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "rand_uniform_int": [-121, 125] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": 2 - }, - { - "tensor": { - "name": "weight", - "sizes": [32, 4], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "pad_columns": { - "source": { - "pad_columns": { - "source": { - "rand_uniform_int": [-128, 127] - }, - "valid_columns": 3 - } - }, - "valid_columns": 3 - } - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [32], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "row_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [32], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - { - "tensor": { - "name": "bias", - "sizes": [32], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "repeat": [0] - } - } - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [0, 0] - }, - { - "int_list": [1, 1] - }, - { - "int": 1 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [1, 32, 64, 64], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "q8ta/conv2d/pw_correctness (12)", - "operator": "et_vk.q8ta_conv2d_pw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "v_ROUTE", - "sweep_variables": { - "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 3, 64, 64], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "rand_uniform_int": [-121, 125] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": 2 - }, - { - "tensor": { - "name": "weight", - "sizes": [32, 4], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "pad_columns": { - "source": { - "pad_columns": { - "source": { - "rand_uniform_int": [-128, 127] - }, - "valid_columns": 3 - } - }, - "valid_columns": 3 - } - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [32], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "row_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [32], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - { - "tensor": { - "name": "bias", - "sizes": [32], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "repeat": [0] - } - } - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [0, 0] - }, - { - "int_list": [1, 1] - }, - { - "int": 1 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [1, 32, 64, 64], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4c", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "q8ta/conv2d/pw_correctness (13)", - "operator": "et_vk.q8ta_conv2d_pw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "v_ROUTE", - "sweep_variables": { - "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 32, 32, 32], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "rand_uniform_int": [-121, 125] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": 2 - }, - { - "tensor": { - "name": "weight", - "sizes": [64, 32], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "pad_columns": { - "source": { - "rand_uniform_int": [-128, 127] - }, - "valid_columns": 32 - } - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [64], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "row_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [64], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - { - "tensor": { - "name": "bias", - "sizes": [64], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "repeat": [0] - } - } - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [0, 0] - }, - { - "int_list": [1, 1] - }, - { - "int": 1 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [1, 64, 32, 32], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4c1w", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "q8ta/conv2d/pw_correctness (14)", - "operator": "et_vk.q8ta_conv2d_pw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "v_ROUTE", - "sweep_variables": { - "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto", "legacy_4w4c"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 32, 32, 32], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "rand_uniform_int": [-121, 125] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": 2 - }, - { - "tensor": { - "name": "weight", - "sizes": [64, 32], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "pad_columns": { - "source": { - "rand_uniform_int": [-128, 127] - }, - "valid_columns": 32 - } - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [64], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "row_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [64], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - { - "tensor": { - "name": "bias", - "sizes": [64], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "repeat": [0] - } - } - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [0, 0] - }, - { - "int_list": [1, 1] - }, - { - "int": 1 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [1, 64, 32, 32], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "q8ta/conv2d/pw_correctness (15)", - "operator": "et_vk.q8ta_conv2d_pw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "v_ROUTE", - "sweep_variables": { - "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 32, 32, 32], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "rand_uniform_int": [-121, 125] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": 2 - }, - { - "tensor": { - "name": "weight", - "sizes": [64, 32], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "pad_columns": { - "source": { - "rand_uniform_int": [-128, 127] - }, - "valid_columns": 32 - } - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [64], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "row_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [64], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - { - "tensor": { - "name": "bias", - "sizes": [64], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "repeat": [0] - } - } - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [0, 0] - }, - { - "int_list": [1, 1] - }, - { - "int": 1 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [1, 64, 32, 32], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4c", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "q8ta/conv2d/pw_correctness (16)", - "operator": "et_vk.q8ta_conv2d_pw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "v_ROUTE", - "sweep_variables": { - "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 64, 16, 16], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "rand_uniform_int": [-121, 125] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": 2 - }, - { - "tensor": { - "name": "weight", - "sizes": [96, 64], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "pad_columns": { - "source": { - "rand_uniform_int": [-128, 127] - }, - "valid_columns": 64 - } - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [96], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "row_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [96], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - { - "tensor": { - "name": "bias", - "sizes": [96], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "repeat": [0] - } - } - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [0, 0] - }, - { - "int_list": [1, 1] - }, - { - "int": 1 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [1, 96, 16, 16], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4c1w", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "q8ta/conv2d/pw_correctness (17)", - "operator": "et_vk.q8ta_conv2d_pw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "v_ROUTE", - "sweep_variables": { - "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto", "legacy_4w4c"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 64, 16, 16], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "rand_uniform_int": [-121, 125] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": 2 - }, - { - "tensor": { - "name": "weight", - "sizes": [96, 64], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "pad_columns": { - "source": { - "rand_uniform_int": [-128, 127] - }, - "valid_columns": 64 - } - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [96], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "row_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [96], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - { - "tensor": { - "name": "bias", - "sizes": [96], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "repeat": [0] - } - } - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [0, 0] - }, - { - "int_list": [1, 1] - }, - { - "int": 1 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [1, 96, 16, 16], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "q8ta/conv2d/pw_correctness (18)", - "operator": "et_vk.q8ta_conv2d_pw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "v_ROUTE", - "sweep_variables": { - "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 64, 16, 16], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "rand_uniform_int": [-121, 125] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": 2 - }, - { - "tensor": { - "name": "weight", - "sizes": [96, 64], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "pad_columns": { - "source": { - "rand_uniform_int": [-128, 127] - }, - "valid_columns": 64 - } - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [96], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "row_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [96], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - { - "tensor": { - "name": "bias", - "sizes": [96], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "repeat": [0] - } - } - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [0, 0] - }, - { - "int_list": [1, 1] - }, - { - "int": 1 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [1, 96, 16, 16], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4c", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "q8ta/conv2d/pw_correctness (19)", - "operator": "et_vk.q8ta_conv2d_pw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "v_ROUTE", - "sweep_variables": { - "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 7, 57, 33], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "rand_uniform_int": [-121, 125] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": 2 - }, - { - "tensor": { - "name": "weight", - "sizes": [13, 8], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "pad_columns": { - "source": { - "pad_columns": { - "source": { - "rand_uniform_int": [-128, 127] - }, - "valid_columns": 7 - } - }, - "valid_columns": 7 - } - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [16], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "row_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [16], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - { - "tensor": { - "name": "bias", - "sizes": [16], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "repeat": [0] - } - } - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [0, 0] - }, - { - "int_list": [1, 1] - }, - { - "int": 1 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [1, 13, 57, 33], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4c1w", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "q8ta/conv2d/pw_correctness (20)", - "operator": "et_vk.q8ta_conv2d_pw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "v_ROUTE", - "sweep_variables": { - "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto", "legacy_4w4c"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 7, 57, 33], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "rand_uniform_int": [-121, 125] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": 2 - }, - { - "tensor": { - "name": "weight", - "sizes": [13, 8], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "pad_columns": { - "source": { - "pad_columns": { - "source": { - "rand_uniform_int": [-128, 127] - }, - "valid_columns": 7 - } - }, - "valid_columns": 7 - } - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [16], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "row_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [16], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - { - "tensor": { - "name": "bias", - "sizes": [16], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "repeat": [0] - } - } - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [0, 0] - }, - { - "int_list": [1, 1] - }, - { - "int": 1 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [1, 13, 57, 33], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "q8ta/conv2d/pw_correctness (21)", - "operator": "et_vk.q8ta_conv2d_pw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "v_ROUTE", - "sweep_variables": { - "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 7, 57, 33], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "rand_uniform_int": [-121, 125] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": 2 - }, - { - "tensor": { - "name": "weight", - "sizes": [13, 8], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "pad_columns": { - "source": { - "pad_columns": { - "source": { - "rand_uniform_int": [-128, 127] - }, - "valid_columns": 7 - } - }, - "valid_columns": 7 - } - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [16], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "row_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [16], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - { - "tensor": { - "name": "bias", - "sizes": [16], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "repeat": [0] - } - } - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [0, 0] - }, - { - "int_list": [1, 1] - }, - { - "int": 1 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [1, 13, 57, 33], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4c", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "q8ta/conv2d/pw_correctness (22)", - "operator": "et_vk.q8ta_conv2d_pw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "v_ROUTE", - "sweep_variables": { - "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 40, 64, 64], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "rand_uniform_int": [-121, 125] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": 2 - }, - { - "tensor": { - "name": "weight", - "sizes": [80, 40], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "pad_columns": { - "source": { - "rand_uniform_int": [-128, 127] - }, - "valid_columns": 40 - } - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [80], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "row_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [80], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - { - "tensor": { - "name": "bias", - "sizes": [80], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "repeat": [0] - } - } - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [0, 0] - }, - { - "int_list": [1, 1] - }, - { - "int": 1 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [1, 80, 64, 64], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4c1w", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "q8ta/conv2d/pw_correctness (23)", - "operator": "et_vk.q8ta_conv2d_pw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "v_ROUTE", - "sweep_variables": { - "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto", "legacy_4w4c"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 40, 64, 64], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "rand_uniform_int": [-121, 125] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": 2 - }, - { - "tensor": { - "name": "weight", - "sizes": [80, 40], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "pad_columns": { - "source": { - "rand_uniform_int": [-128, 127] - }, - "valid_columns": 40 - } - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [80], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "row_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [80], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - { - "tensor": { - "name": "bias", - "sizes": [80], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "repeat": [0] - } - } - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [0, 0] - }, - { - "int_list": [1, 1] - }, - { - "int": 1 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [1, 80, 64, 64], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "q8ta/conv2d/pw_correctness (24)", - "operator": "et_vk.q8ta_conv2d_pw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "v_ROUTE", - "sweep_variables": { - "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 40, 64, 64], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "rand_uniform_int": [-121, 125] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": 2 - }, - { - "tensor": { - "name": "weight", - "sizes": [80, 40], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "pad_columns": { - "source": { - "rand_uniform_int": [-128, 127] - }, - "valid_columns": 40 - } - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [80], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "row_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [80], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - { - "tensor": { - "name": "bias", - "sizes": [80], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "repeat": [0] - } - } - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [0, 0] - }, - { - "int_list": [1, 1] - }, - { - "int": 1 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [1, 80, 64, 64], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4c", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "q8ta/conv2d/pw_correctness (25)", - "operator": "et_vk.q8ta_conv2d_pw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "v_ROUTE", - "sweep_variables": { - "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto"], - "v_OUTPUT_LAYOUT": ["4c1w", "4w4c"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [2, 8, 8, 8], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "rand_uniform_int": [-121, 125] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": 2 - }, - { - "tensor": { - "name": "weight", - "sizes": [8, 8], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "pad_columns": { - "source": { - "rand_uniform_int": [-128, 127] - }, - "valid_columns": 8 - } - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [8], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "row_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [8], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - { - "tensor": { - "name": "bias", - "sizes": [8], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "repeat": [0] - } - } - }, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [0, 0] - }, - { - "int_list": [1, 1] - }, - { - "int": 1 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [2, 8, 8, 8], - "dtype": "int8x4", - "storage": "buffer", - "layout": "v_OUTPUT_LAYOUT", - "data": { - "fill": 0 - } - } - } - ] - ] - }, - { - "name": "q8ta/conv2d/pw_correctness (26)", - "operator": "et_vk.q8ta_conv2d_pw.default", - "target_ms": 100, - "benchmark_runs": 1, - "warmup_runs": 1, - "max_dispatches": 200, - "validation_method": { - "function": "is_close", - "atol": 1.0018818534756524, - "rtol": 0 - }, - "route": "v_ROUTE", - "sweep_variables": { - "v_ROUTE": ["auto", "pw_signed", "pw_unsigned", "pw_auto"] - }, - "args_sweep": [ - [ - { - "tensor": { - "name": "input", - "sizes": [1, 7, 7, 5], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "rand_uniform_int": [-128, -5] - } - } - }, - { - "float": 0.008123000152409077 - }, - { - "int": -128 - }, - { - "tensor": { - "name": "weight", - "sizes": [13, 8], - "dtype": "int8", - "storage": "constant", - "layout": "width", - "data": { - "pad_columns": { - "source": { - "pad_columns": { - "source": { - "rand_uniform_int": [-128, 127] - }, - "valid_columns": 7 - } - }, - "valid_columns": 7 - } - } - } - }, - { - "tensor": { - "name": "weight_sums", - "sizes": [16], - "dtype": "int32", - "storage": "constant", - "layout": "width", - "data": { - "row_sum": "weight" - } - } - }, - { - "tensor": { - "name": "weight_scales", - "sizes": [16], - "dtype": "float32", - "storage": "constant", - "layout": "width", - "data": { - "rand_uniform_float": [0.004999999888241291, 0.014999999664723873] - } - } - }, - { - "float": 0.05313999950885773 - }, - { - "int": -1 - }, - null, - { - "int_list": [1, 1] - }, - { - "int_list": [1, 1] - }, - { - "int_list": [0, 0] - }, - { - "int_list": [1, 1] - }, - { - "int": 1 - }, - { - "string": "none" - }, - { - "tensor": { - "name": "output", - "sizes": [1, 13, 7, 5], - "dtype": "int8x4", - "storage": "buffer", - "layout": "4w4c", - "data": { - "fill": 0 - } - } - } - ] - ] - } -] diff --git a/backends/vulkan/test/ops/benchmark_v2/operators/embedding/qw/q4gsw/cases/correctness.json b/backends/vulkan/test/ops/benchmark_v2/operators/embedding/qw/q4gsw/cases/correctness.json index 2e90ae24b73..1334bc32b52 100644 --- a/backends/vulkan/test/ops/benchmark_v2/operators/embedding/qw/q4gsw/cases/correctness.json +++ b/backends/vulkan/test/ops/benchmark_v2/operators/embedding/qw/q4gsw/cases/correctness.json @@ -390,7 +390,7 @@ "rtol": 0.001 }, "sweep_variables": { - "v_STORAGE": ["buffer", "texture3d"], + "v_STORAGE": ["buffer"], "v_OUTPUT_DTYPE": ["float16", "float32"] }, "args_sweep": [ @@ -545,7 +545,7 @@ "rtol": 0.001 }, "sweep_variables": { - "v_STORAGE": ["buffer", "texture3d"], + "v_STORAGE": ["buffer"], "v_OUTPUT_DTYPE": ["float16", "float32"] }, "args_sweep": [ @@ -700,7 +700,7 @@ "rtol": 0.001 }, "sweep_variables": { - "v_STORAGE": ["buffer", "texture3d"], + "v_STORAGE": ["buffer"], "v_OUTPUT_DTYPE": ["float16", "float32"] }, "args_sweep": [ @@ -918,5 +918,79 @@ } ] ] + }, + { + "name": "embedding_q4gsw performance llama_3_2_1b_prefill_fp32_linear_weight buffer", + "operator": "et_vk.embedding_q4gsw.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.002, + "rtol": 0.001 + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "weight", + "sizes": [128256, 1024], + "dtype": "uint8", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_int": [0, 255] + } + } + }, + { + "tensor": { + "name": "scales", + "sizes": [128256, 64], + "dtype": "float16", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.005, 0.015] + } + } + }, + { + "int": 32 + }, + { + "tensor": { + "name": "indices", + "sizes": [1, 2047], + "dtype": "int32", + "storage": "buffer", + "layout": "width", + "data": { + "abs_mod": { + "bounds": [-10, 10], + "mod": 128256 + } + } + } + }, + { + "bool": true + }, + { + "tensor": { + "name": "output", + "sizes": [1, 2047, 2048], + "dtype": "float32", + "storage": "buffer", + "layout": "width", + "data": { + "fill": 0 + } + } + } + ] + ] } ] diff --git a/backends/vulkan/test/ops/benchmark_v2/operators/embedding/qw/q4gsw/cases/texture3d_multirow_indices.json b/backends/vulkan/test/ops/benchmark_v2/operators/embedding/qw/q4gsw/cases/texture3d_multirow_indices.json new file mode 100644 index 00000000000..48685e436b3 --- /dev/null +++ b/backends/vulkan/test/ops/benchmark_v2/operators/embedding/qw/q4gsw/cases/texture3d_multirow_indices.json @@ -0,0 +1,236 @@ +[ + { + "name": "embedding_q4gsw correctness (6)", + "operator": "et_vk.embedding_q4gsw.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.002, + "rtol": 0.001 + }, + "sweep_variables": { + "v_STORAGE": ["texture3d"], + "v_OUTPUT_DTYPE": ["float16", "float32"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "weight", + "sizes": [32, 32], + "dtype": "uint8", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_int": [0, 255] + } + } + }, + { + "tensor": { + "name": "scales", + "sizes": [32, 2], + "dtype": "float16", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.005, 0.015] + } + } + }, + { + "int": 32 + }, + { + "tensor": { + "name": "indices", + "sizes": [2, 3], + "dtype": "int32", + "storage": "v_STORAGE", + "layout": "width", + "data": { + "abs_mod": { + "bounds": [-10, 10], + "mod": 32 + } + } + } + }, + { + "bool": false + }, + { + "tensor": { + "name": "output", + "sizes": [2, 3, 64], + "dtype": "v_OUTPUT_DTYPE", + "storage": "v_STORAGE", + "layout": "width", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "embedding_q4gsw correctness (8)", + "operator": "et_vk.embedding_q4gsw.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.002, + "rtol": 0.001 + }, + "sweep_variables": { + "v_STORAGE": ["texture3d"], + "v_OUTPUT_DTYPE": ["float16", "float32"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "weight", + "sizes": [100, 64], + "dtype": "uint8", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_int": [0, 255] + } + } + }, + { + "tensor": { + "name": "scales", + "sizes": [100, 4], + "dtype": "float16", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.005, 0.015] + } + } + }, + { + "int": 32 + }, + { + "tensor": { + "name": "indices", + "sizes": [4, 8], + "dtype": "int32", + "storage": "v_STORAGE", + "layout": "width", + "data": { + "abs_mod": { + "bounds": [-10, 10], + "mod": 100 + } + } + } + }, + { + "bool": false + }, + { + "tensor": { + "name": "output", + "sizes": [4, 8, 128], + "dtype": "v_OUTPUT_DTYPE", + "storage": "v_STORAGE", + "layout": "width", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "embedding_q4gsw correctness (10)", + "operator": "et_vk.embedding_q4gsw.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.002, + "rtol": 0.001 + }, + "sweep_variables": { + "v_STORAGE": ["texture3d"], + "v_OUTPUT_DTYPE": ["float16", "float32"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "weight", + "sizes": [50, 32], + "dtype": "uint8", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_int": [0, 255] + } + } + }, + { + "tensor": { + "name": "scales", + "sizes": [50, 1], + "dtype": "float16", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.005, 0.015] + } + } + }, + { + "int": 64 + }, + { + "tensor": { + "name": "indices", + "sizes": [2, 4], + "dtype": "int32", + "storage": "v_STORAGE", + "layout": "width", + "data": { + "abs_mod": { + "bounds": [-10, 10], + "mod": 50 + } + } + } + }, + { + "bool": false + }, + { + "tensor": { + "name": "output", + "sizes": [2, 4, 64], + "dtype": "v_OUTPUT_DTYPE", + "storage": "v_STORAGE", + "layout": "width", + "data": { + "fill": 0 + } + } + } + ] + ] + } +] diff --git a/backends/vulkan/test/ops/benchmark_v2/operators/gemm/linear/qw/q4gsw/cases/correctness.json b/backends/vulkan/test/ops/benchmark_v2/operators/gemm/linear/qw/q4gsw/cases/correctness.json index 5d452a04a64..47c4fadbcab 100644 --- a/backends/vulkan/test/ops/benchmark_v2/operators/gemm/linear/qw/q4gsw/cases/correctness.json +++ b/backends/vulkan/test/ops/benchmark_v2/operators/gemm/linear/qw/q4gsw/cases/correctness.json @@ -6286,5 +6286,293 @@ } ] ] + }, + { + "name": "q4gsw_linear performance [128, 2048, 2048, 128] (3)", + "operator": "et_vk.linear_q4gsw.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.002, + "rtol": 0.001 + }, + "sweep_variables": { + "v_STORAGE": ["texture3d"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [128, 2048], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "width", + "data": { + "rand_uniform_int": [-10, 10] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [2048, 1024], + "dtype": "uint8", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_int": [0, 255] + } + } + }, + { + "tensor": { + "name": "scales", + "sizes": [16, 2048], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.005, 0.015] + } + } + }, + { + "int": 128 + }, + null, + { + "tensor": { + "name": "output", + "sizes": [128, 2048], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "width", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q4gsw_linear performance [128, 2048, 2048, 128] (3)", + "operator": "et_vk.linear_q4gsw.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.002, + "rtol": 0.001 + }, + "sweep_variables": { + "v_STORAGE": ["buffer"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [128, 2048], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "width", + "data": { + "rand_uniform_int": [-10, 10] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [2048, 1024], + "dtype": "uint8", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_int": [0, 255] + } + } + }, + { + "tensor": { + "name": "scales", + "sizes": [16, 2048], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.005, 0.015] + } + } + }, + { + "int": 128 + }, + null, + { + "tensor": { + "name": "output", + "sizes": [128, 2048], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "width", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q4gsw_linear performance [128, 2048, 2048, 128] float16 width (6)", + "operator": "et_vk.linear_q4gsw.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 4.525483399593905, + "rtol": 0.1 + }, + "sweep_variables": { + "v_STORAGE": ["texture3d"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [128, 2048], + "dtype": "float16", + "storage": "v_STORAGE", + "layout": "width", + "data": { + "rand_uniform_int": [-10, 10] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [2048, 1024], + "dtype": "uint8", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_int": [0, 255] + } + } + }, + { + "tensor": { + "name": "scales", + "sizes": [16, 2048], + "dtype": "float16", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.005, 0.015] + } + } + }, + { + "int": 128 + }, + null, + { + "tensor": { + "name": "output", + "sizes": [128, 2048], + "dtype": "float16", + "storage": "v_STORAGE", + "layout": "width", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q4gsw_linear performance [128, 2048, 2048, 128] float16 width (6)", + "operator": "et_vk.linear_q4gsw.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 4.525483399593905, + "rtol": 0.1 + }, + "sweep_variables": { + "v_STORAGE": ["buffer"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [128, 2048], + "dtype": "float16", + "storage": "v_STORAGE", + "layout": "width", + "data": { + "rand_uniform_int": [-10, 10] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [2048, 1024], + "dtype": "uint8", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_int": [0, 255] + } + } + }, + { + "tensor": { + "name": "scales", + "sizes": [16, 2048], + "dtype": "float16", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [0.005, 0.015] + } + } + }, + { + "int": 128 + }, + null, + { + "tensor": { + "name": "output", + "sizes": [128, 2048], + "dtype": "float16", + "storage": "v_STORAGE", + "layout": "width", + "data": { + "fill": 0 + } + } + } + ] + ] } ] diff --git a/backends/vulkan/test/ops/benchmark_v2/operators/gemm/matmul/cases/correctness.json b/backends/vulkan/test/ops/benchmark_v2/operators/gemm/matmul/cases/correctness.json index f5286476345..2cd1d82b2c9 100644 --- a/backends/vulkan/test/ops/benchmark_v2/operators/gemm/matmul/cases/correctness.json +++ b/backends/vulkan/test/ops/benchmark_v2/operators/gemm/matmul/cases/correctness.json @@ -3677,5 +3677,1292 @@ } ] ] + }, + { + "name": "mm performance [0,4096,64,128] aten.linear.default buffer float16 constant=False", + "operator": "aten.linear.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0, + "rtol": 0.1 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["auto"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [4096, 64], + "dtype": "float16", + "storage": "buffer", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [128, 64], + "dtype": "float16", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "tensor": { + "name": "output", + "sizes": [4096, 128], + "dtype": "float16", + "storage": "buffer", + "layout": "width", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "mm performance [0,4096,64,128] aten.linear.default texture3d float16 constant=False auto", + "operator": "aten.linear.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0, + "rtol": 0.1 + }, + "route": "auto", + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [4096, 64], + "dtype": "float16", + "storage": "texture3d", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [128, 64], + "dtype": "float16", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "tensor": { + "name": "output", + "sizes": [4096, 128], + "dtype": "float16", + "storage": "texture3d", + "layout": "width", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "mm performance [0,4096,64,128] aten.mm.default buffer float16", + "operator": "aten.mm.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0, + "rtol": 0.1 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["auto"], + "v_WEIGHT_STORAGE": ["buffer"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [4096, 64], + "dtype": "float16", + "storage": "buffer", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 128], + "dtype": "float16", + "storage": "v_WEIGHT_STORAGE", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "output", + "sizes": [4096, 128], + "dtype": "float16", + "storage": "buffer", + "layout": "width", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "mm performance [0,4096,64,128] aten.mm.default texture3d float16", + "operator": "aten.mm.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0, + "rtol": 0.1 + }, + "route": "auto", + "sweep_variables": { + "v_WEIGHT_STORAGE": ["texture3d"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [4096, 64], + "dtype": "float16", + "storage": "texture3d", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 128], + "dtype": "float16", + "storage": "v_WEIGHT_STORAGE", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "output", + "sizes": [4096, 128], + "dtype": "float16", + "storage": "texture3d", + "layout": "width", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "mm performance [0,4096,64,128] aten.addmm.default buffer float16", + "operator": "aten.addmm.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0, + "rtol": 0.1 + }, + "route": "auto", + "sweep_variables": { + "v_WEIGHT_STORAGE": ["buffer"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "bias", + "sizes": [128], + "dtype": "float16", + "storage": "buffer", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "input", + "sizes": [4096, 64], + "dtype": "float16", + "storage": "buffer", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 128], + "dtype": "float16", + "storage": "v_WEIGHT_STORAGE", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "float": 1.0 + }, + { + "float": 1.0 + }, + { + "tensor": { + "name": "output", + "sizes": [4096, 128], + "dtype": "float16", + "storage": "buffer", + "layout": "width", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "mm performance [0,4096,64,128] aten.addmm.default buffer float16", + "operator": "aten.addmm.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0, + "rtol": 0.1 + }, + "route": "auto", + "sweep_variables": { + "v_WEIGHT_STORAGE": ["constant"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "bias", + "sizes": [128], + "dtype": "float16", + "storage": "buffer", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "input", + "sizes": [4096, 64], + "dtype": "float16", + "storage": "buffer", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 128], + "dtype": "float16", + "storage": "v_WEIGHT_STORAGE", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "float": 1.0 + }, + { + "float": 1.0 + }, + { + "tensor": { + "name": "output", + "sizes": [4096, 128], + "dtype": "float16", + "storage": "buffer", + "layout": "width", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "mm performance [0,4096,64,128] aten.addmm.default texture3d float16", + "operator": "aten.addmm.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0, + "rtol": 0.1 + }, + "route": "auto", + "sweep_variables": { + "v_WEIGHT_STORAGE": ["texture3d"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "bias", + "sizes": [128], + "dtype": "float16", + "storage": "texture3d", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "input", + "sizes": [4096, 64], + "dtype": "float16", + "storage": "texture3d", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 128], + "dtype": "float16", + "storage": "v_WEIGHT_STORAGE", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "float": 1.0 + }, + { + "float": 1.0 + }, + { + "tensor": { + "name": "output", + "sizes": [4096, 128], + "dtype": "float16", + "storage": "texture3d", + "layout": "width", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "mm performance [0,4096,64,128] aten.addmm.default texture3d float16", + "operator": "aten.addmm.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0, + "rtol": 0.1 + }, + "route": "auto", + "sweep_variables": { + "v_WEIGHT_STORAGE": ["constant"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "bias", + "sizes": [128], + "dtype": "float16", + "storage": "texture3d", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "input", + "sizes": [4096, 64], + "dtype": "float16", + "storage": "texture3d", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 128], + "dtype": "float16", + "storage": "v_WEIGHT_STORAGE", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "float": 1.0 + }, + { + "float": 1.0 + }, + { + "tensor": { + "name": "output", + "sizes": [4096, 128], + "dtype": "float16", + "storage": "texture3d", + "layout": "width", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "mm performance [0,4096,64,128] aten.linear.default", + "operator": "aten.linear.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "route": "auto", + "sweep_variables": { + "v_STORAGE": ["texture3d"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [4096, 64], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [128, 64], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "tensor": { + "name": "output", + "sizes": [4096, 128], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "width", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "mm performance [0,4096,64,128] aten.linear.default", + "operator": "aten.linear.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "route": "auto", + "sweep_variables": { + "v_STORAGE": ["buffer"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [4096, 64], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [128, 64], + "dtype": "float32", + "storage": "constant", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "tensor": { + "name": "output", + "sizes": [4096, 128], + "dtype": "float32", + "storage": "v_STORAGE", + "layout": "width", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "mm performance [0,4096,64,128] aten.mm.default buffer float32 float32 buffer width", + "operator": "aten.mm.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["auto"], + "v_WEIGHT_STORAGE": ["buffer"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [4096, 64], + "dtype": "float32", + "storage": "buffer", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 128], + "dtype": "float32", + "storage": "v_WEIGHT_STORAGE", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "output", + "sizes": [4096, 128], + "dtype": "float32", + "storage": "buffer", + "layout": "width", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "mm performance [0,4096,64,128] aten.mm.default texture3d float32", + "operator": "aten.mm.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "route": "auto", + "sweep_variables": { + "v_WEIGHT_STORAGE": ["texture3d"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [4096, 64], + "dtype": "float32", + "storage": "texture3d", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 128], + "dtype": "float32", + "storage": "v_WEIGHT_STORAGE", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "output", + "sizes": [4096, 128], + "dtype": "float32", + "storage": "texture3d", + "layout": "width", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "mm performance [0,4096,64,128] aten.addmm.default buffer float32", + "operator": "aten.addmm.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "route": "auto", + "sweep_variables": { + "v_WEIGHT_STORAGE": ["buffer"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "bias", + "sizes": [128], + "dtype": "float32", + "storage": "buffer", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "input", + "sizes": [4096, 64], + "dtype": "float32", + "storage": "buffer", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 128], + "dtype": "float32", + "storage": "v_WEIGHT_STORAGE", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "float": 1.0 + }, + { + "float": 1.0 + }, + { + "tensor": { + "name": "output", + "sizes": [4096, 128], + "dtype": "float32", + "storage": "buffer", + "layout": "width", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "mm performance [0,4096,64,128] aten.addmm.default buffer float32", + "operator": "aten.addmm.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "route": "auto", + "sweep_variables": { + "v_WEIGHT_STORAGE": ["constant"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "bias", + "sizes": [128], + "dtype": "float32", + "storage": "buffer", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "input", + "sizes": [4096, 64], + "dtype": "float32", + "storage": "buffer", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 128], + "dtype": "float32", + "storage": "v_WEIGHT_STORAGE", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "float": 1.0 + }, + { + "float": 1.0 + }, + { + "tensor": { + "name": "output", + "sizes": [4096, 128], + "dtype": "float32", + "storage": "buffer", + "layout": "width", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "mm performance [0,4096,64,128] aten.addmm.default texture3d float32", + "operator": "aten.addmm.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "route": "auto", + "sweep_variables": { + "v_WEIGHT_STORAGE": ["texture3d"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "bias", + "sizes": [128], + "dtype": "float32", + "storage": "texture3d", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "input", + "sizes": [4096, 64], + "dtype": "float32", + "storage": "texture3d", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 128], + "dtype": "float32", + "storage": "v_WEIGHT_STORAGE", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "float": 1.0 + }, + { + "float": 1.0 + }, + { + "tensor": { + "name": "output", + "sizes": [4096, 128], + "dtype": "float32", + "storage": "texture3d", + "layout": "width", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "mm performance [0,4096,64,128] aten.addmm.default texture3d float32", + "operator": "aten.addmm.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "route": "auto", + "sweep_variables": { + "v_WEIGHT_STORAGE": ["constant"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "bias", + "sizes": [128], + "dtype": "float32", + "storage": "texture3d", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "input", + "sizes": [4096, 64], + "dtype": "float32", + "storage": "texture3d", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 128], + "dtype": "float32", + "storage": "v_WEIGHT_STORAGE", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "float": 1.0 + }, + { + "float": 1.0 + }, + { + "tensor": { + "name": "output", + "sizes": [4096, 128], + "dtype": "float32", + "storage": "texture3d", + "layout": "width", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "mm performance [1,256,4096,64] aten.bmm.default buffer float16", + "operator": "aten.bmm.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 4.0, + "rtol": 0.1 + }, + "route": "auto", + "sweep_variables": { + "v_WEIGHT_STORAGE": ["buffer"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 256, 4096], + "dtype": "float16", + "storage": "buffer", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [1, 4096, 64], + "dtype": "float16", + "storage": "v_WEIGHT_STORAGE", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "output", + "sizes": [1, 256, 64], + "dtype": "float16", + "storage": "buffer", + "layout": "width", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "mm performance [1,256,4096,64] aten.bmm.default buffer float16", + "operator": "aten.bmm.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 4.0, + "rtol": 0.1 + }, + "route": "auto", + "sweep_variables": { + "v_WEIGHT_STORAGE": ["constant"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 256, 4096], + "dtype": "float16", + "storage": "buffer", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [1, 4096, 64], + "dtype": "float16", + "storage": "v_WEIGHT_STORAGE", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "output", + "sizes": [1, 256, 64], + "dtype": "float16", + "storage": "buffer", + "layout": "width", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "mm performance [1,256,4096,64] aten.bmm.default texture3d float16", + "operator": "aten.bmm.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0, + "rtol": 0.1 + }, + "route": "auto", + "sweep_variables": { + "v_WEIGHT_STORAGE": ["texture3d"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 256, 4096], + "dtype": "float16", + "storage": "texture3d", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [1, 4096, 64], + "dtype": "float16", + "storage": "v_WEIGHT_STORAGE", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "output", + "sizes": [1, 256, 64], + "dtype": "float16", + "storage": "texture3d", + "layout": "width", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "mm performance [1,256,4096,64] aten.bmm.default texture3d float16", + "operator": "aten.bmm.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0, + "rtol": 0.1 + }, + "route": "auto", + "sweep_variables": { + "v_WEIGHT_STORAGE": ["constant"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 256, 4096], + "dtype": "float16", + "storage": "texture3d", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [1, 4096, 64], + "dtype": "float16", + "storage": "v_WEIGHT_STORAGE", + "layout": "width", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "output", + "sizes": [1, 256, 64], + "dtype": "float16", + "storage": "texture3d", + "layout": "width", + "data": { + "fill": 0 + } + } + } + ] + ] } ] From 3dfe38d5d3ea79e47afbab599de92e7144556309 Mon Sep 17 00:00:00 2001 From: Stephen Jia Date: Tue, 3 Nov 2026 11:49:43 -0800 Subject: [PATCH 035/128] [ET-VK][ci] Build benchmark_v2 with CMake and run it in GitHub CI Pull Request resolved: https://github.com/pytorch/executorch/pull/24519 ## Ulterior Motive Deprecate the legacy operator benchmark (`etvk_bench`) in favor of `benchmark_v2` (`etvk_ops_bench`). After the Skycastle workflows, GitHub CI holds the last uses of `etvk_bench`. ## Rationale **What**: Add a CMake build for `etvk_ops_bench` (`CMakeLists.txt`, `build.sh`), and switch GitHub's `test-vulkan-operators-linux` (`pull.yml`, SwiftShader) and `test-vulkan-nvidia` (`vulkan.yml`, NVIDIA GPU) from `etvk_bench` to `etvk_ops_bench --validation_only` on each operator directory's `correctness.json`. **Why**: `benchmark_v2` had only a Buck build, so GitHub CI could not run it. ## Details - GitHub CI operator steps (both jobs) - **Before**: `benchmark/build_and_run.sh`, then 8 `etvk_bench --op=... [--set=...]` commands, some including performance sets. - **After**: `benchmark_v2/build.sh`, then a loop over the same 8 operator directories (`binary/add`, `gemm/linear/qw/q8csw`, `convolution/qw/q8csw`, `gemm/linear/qw/q4gsw`, `quantization/choose_qparams_per_row`, `quantize/q8ta`, `clone/q8ta`, `binary/q8ta`). Each runs its `correctness.json` with `--validation_only`. A failing case makes the binary exit 1, which fails the job under `set -eux`. - `CMakeLists.txt`, modeled on the legacy benchmark's: - Finds the installed ExecuTorch with `vulkan_backend`, and gflags in the main build tree. - Builds the prototype `q4gsw` GEMV shaders with `gen_vulkan_shader_lib_cpp` and `vulkan_shader_lib`. - Compiles every `framework/` and `operators/` source except tests into the executable, so the static route, reference, throughput and check registrations survive. This is the same 69-file source set as the Buck target. - Uses C++20 and the nlohmann JSON headers in `third-party/json`. - `build.sh` builds ExecuTorch with Vulkan into `cmake-out/`, then `etvk_ops_bench`; `--android` cross-compiles into `cmake-android-out/`. - READMEs: a "Building with CMake" section, and the GitHub job in the internal CI section. Usage, from the ExecuTorch root: ```sh backends/vulkan/test/ops/benchmark_v2/build.sh cmake-out/backends/vulkan/test/ops/benchmark_v2/etvk_ops_bench \ --config=backends/vulkan/test/ops/benchmark_v2/operators/binary/add/cases/correctness.json \ --validation_only ``` Authored with Claude Code (AI). ghstack-source-id: 463942916 @exported-using-ghexport Differential Revision: [D129408342](https://our.internmc.facebook.com/intern/diff/D129408342/) --- .github/workflows/pull.yml | 15 ++-- .github/workflows/vulkan.yml | 19 +++-- .../test/ops/benchmark_v2/CMakeLists.txt | 72 +++++++++++++++++++ .../vulkan/test/ops/benchmark_v2/README.md | 13 ++++ .../vulkan/test/ops/benchmark_v2/build.sh | 49 +++++++++++++ 5 files changed, 148 insertions(+), 20 deletions(-) create mode 100644 backends/vulkan/test/ops/benchmark_v2/CMakeLists.txt create mode 100755 backends/vulkan/test/ops/benchmark_v2/build.sh diff --git a/.github/workflows/pull.yml b/.github/workflows/pull.yml index 9d79d795dfe..8a7494f02e3 100644 --- a/.github/workflows/pull.yml +++ b/.github/workflows/pull.yml @@ -1843,15 +1843,12 @@ jobs: .ci/scripts/setup-linux.sh --build-tool "cmake" # Custom operator tests - PYTHON_EXECUTABLE=python bash backends/vulkan/test/ops/benchmark/build_and_run.sh - ./cmake-out/backends/vulkan/test/ops/benchmark/etvk_bench --op=add - ./cmake-out/backends/vulkan/test/ops/benchmark/etvk_bench --op=q8csw_linear --set=correctness,performance - ./cmake-out/backends/vulkan/test/ops/benchmark/etvk_bench --op=q8csw_conv2d - ./cmake-out/backends/vulkan/test/ops/benchmark/etvk_bench --op=q4gsw_linear --set=correctness,performance - ./cmake-out/backends/vulkan/test/ops/benchmark/etvk_bench --op=choose_qparams_per_row --set=correctness,performance - ./cmake-out/backends/vulkan/test/ops/benchmark/etvk_bench --op=q8ta/qdq --set=correctness - ./cmake-out/backends/vulkan/test/ops/benchmark/etvk_bench --op=q8ta/clone --set=correctness - ./cmake-out/backends/vulkan/test/ops/benchmark/etvk_bench --op=q8ta/binary --set=correctness + PYTHON_EXECUTABLE=python bash backends/vulkan/test/ops/benchmark_v2/build.sh + for op in binary/add gemm/linear/qw/q8csw convolution/qw/q8csw gemm/linear/qw/q4gsw \ + quantization/choose_qparams_per_row quantize/q8ta clone/q8ta binary/q8ta; do + ./cmake-out/backends/vulkan/test/ops/benchmark_v2/etvk_ops_bench --validation_only \ + --config=backends/vulkan/test/ops/benchmark_v2/operators/${op}/cases/correctness.json + done # "Classic" Operator tests PYTHON_EXECUTABLE=python bash backends/vulkan/test/scripts/test_op.sh --build diff --git a/.github/workflows/vulkan.yml b/.github/workflows/vulkan.yml index 1fce832be6a..d6f71d428ad 100644 --- a/.github/workflows/vulkan.yml +++ b/.github/workflows/vulkan.yml @@ -119,17 +119,14 @@ jobs: done # Operator coverage (mirrors test-vulkan-operators-linux, on real hardware). - # etvk_bench runs GPU microbenchmarks that rely on GPU timestamp queries, - # so it requires the hardware selected above. - PYTHON_EXECUTABLE=python bash backends/vulkan/test/ops/benchmark/build_and_run.sh - ./cmake-out/backends/vulkan/test/ops/benchmark/etvk_bench --op=add - ./cmake-out/backends/vulkan/test/ops/benchmark/etvk_bench --op=q8csw_linear --set=correctness,performance - ./cmake-out/backends/vulkan/test/ops/benchmark/etvk_bench --op=q8csw_conv2d - ./cmake-out/backends/vulkan/test/ops/benchmark/etvk_bench --op=q4gsw_linear --set=correctness,performance - ./cmake-out/backends/vulkan/test/ops/benchmark/etvk_bench --op=choose_qparams_per_row --set=correctness,performance - ./cmake-out/backends/vulkan/test/ops/benchmark/etvk_bench --op=q8ta/qdq --set=correctness - ./cmake-out/backends/vulkan/test/ops/benchmark/etvk_bench --op=q8ta/clone --set=correctness - ./cmake-out/backends/vulkan/test/ops/benchmark/etvk_bench --op=q8ta/binary --set=correctness + # etvk_ops_bench validates each operator's correctness cases on the GPU + # selected above. + PYTHON_EXECUTABLE=python bash backends/vulkan/test/ops/benchmark_v2/build.sh + for op in binary/add gemm/linear/qw/q8csw convolution/qw/q8csw gemm/linear/qw/q4gsw \ + quantization/choose_qparams_per_row quantize/q8ta clone/q8ta binary/q8ta; do + ./cmake-out/backends/vulkan/test/ops/benchmark_v2/etvk_ops_bench --validation_only \ + --config=backends/vulkan/test/ops/benchmark_v2/operators/${op}/cases/correctness.json + done PYTHON_EXECUTABLE=python bash backends/vulkan/test/scripts/test_op.sh --build vulkan_sdpa_av_test diff --git a/backends/vulkan/test/ops/benchmark_v2/CMakeLists.txt b/backends/vulkan/test/ops/benchmark_v2/CMakeLists.txt new file mode 100644 index 00000000000..a35bc0bb623 --- /dev/null +++ b/backends/vulkan/test/ops/benchmark_v2/CMakeLists.txt @@ -0,0 +1,72 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +cmake_minimum_required(VERSION 3.19) +project(etvk_ops_bench) + +if(ANDROID) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY BOTH) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE BOTH) +endif() + +find_package(executorch CONFIG REQUIRED COMPONENTS vulkan_backend) + +if(NOT EXECUTORCH_ROOT) + set(EXECUTORCH_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/../../../../..) +endif() + +if(NOT PYTHON_EXECUTABLE) + set(PYTHON_EXECUTABLE python3) +endif() + +# Include this file to access executorch_target_link_options_shared_lib +include(${EXECUTORCH_ROOT}/tools/cmake/Utils.cmake) +include(${EXECUTORCH_ROOT}/backends/vulkan/cmake/ShaderLibrary.cmake) + +set(VULKAN_CXX_FLAGS -fexceptions -DUSE_VULKAN_WRAPPER -DUSE_VULKAN_VOLK) + +set(VULKAN_THIRD_PARTY_PATH ${EXECUTORCH_ROOT}/backends/vulkan/third-party) +set(COMMON_INCLUDES + ${EXECUTORCH_ROOT}/.. + ${VULKAN_THIRD_PARTY_PATH}/Vulkan-Headers/include + ${VULKAN_THIRD_PARTY_PATH}/volk + ${VULKAN_THIRD_PARTY_PATH}/VulkanMemoryAllocator + ${EXECUTORCH_ROOT}/third-party/json/include +) + +gen_vulkan_shader_lib_cpp( + ${CMAKE_CURRENT_SOURCE_DIR}/operators/gemm/linear/qw/q4gsw/glsl +) +vulkan_shader_lib(prototype_shaderlib ${generated_spv_cpp}) + +# Compiling every source into the executable keeps the static route, reference, +# throughput and check registrations that would be dropped from an archive. +file( + GLOB_RECURSE ETVK_OPS_BENCH_SOURCES + RELATIVE ${CMAKE_CURRENT_SOURCE_DIR} + framework/*.cpp operators/*.cpp +) +list(FILTER ETVK_OPS_BENCH_SOURCES EXCLUDE REGEX "(^|/)test/|Test\\.cpp$") + +# gflags is built by the main ExecuTorch build but not installed, so find it in +# that build tree, which build.sh places five levels above this project's build +# directory. +set(gflags_DIR ${CMAKE_CURRENT_BINARY_DIR}/../../../../../third-party/gflags) +find_package(gflags REQUIRED) + +add_executable( + etvk_ops_bench ${CMAKE_CURRENT_SOURCE_DIR}/etvk_ops_bench.cpp + ${ETVK_OPS_BENCH_SOURCES} +) +target_include_directories(etvk_ops_bench PRIVATE ${COMMON_INCLUDES}) +target_link_libraries( + etvk_ops_bench PRIVATE vulkan_backend executorch_core prototype_shaderlib + gflags +) +target_compile_options(etvk_ops_bench PRIVATE ${VULKAN_CXX_FLAGS}) +set_property(TARGET etvk_ops_bench PROPERTY CXX_STANDARD 20) + +executorch_target_link_options_shared_lib(vulkan_backend) diff --git a/backends/vulkan/test/ops/benchmark_v2/README.md b/backends/vulkan/test/ops/benchmark_v2/README.md index 5b26f7435bf..cf00049aeb0 100644 --- a/backends/vulkan/test/ops/benchmark_v2/README.md +++ b/backends/vulkan/test/ops/benchmark_v2/README.md @@ -31,6 +31,19 @@ Each expanded case runs separately. Shader variants can share one binding interface through `shader_sweep`; operators can sweep argument sets, dtype and storage variables, and execution routes. +## Building with CMake + +From the ExecuTorch root, `build.sh` builds ExecuTorch with the Vulkan backend +into `cmake-out/`, then `etvk_ops_bench`. It requires `glslc` on `PATH`. Pass +`--android` to cross-compile for arm64 Android into `cmake-android-out/` with +the NDK at `ANDROID_NDK`. + +```sh +backends/vulkan/test/ops/benchmark_v2/build.sh +cmake-out/backends/vulkan/test/ops/benchmark_v2/etvk_ops_bench \ + --config=backends/vulkan/test/ops/benchmark_v2/examples/shaders.json +``` + ## Running the binary The commands below assume `etvk_ops_bench` is on `PATH` and the current directory diff --git a/backends/vulkan/test/ops/benchmark_v2/build.sh b/backends/vulkan/test/ops/benchmark_v2/build.sh new file mode 100755 index 00000000000..05aa142b271 --- /dev/null +++ b/backends/vulkan/test/ops/benchmark_v2/build.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# Builds ExecuTorch with the Vulkan backend, then etvk_ops_bench, with CMake. +# Run from the ExecuTorch root. Requires glslc on PATH, and ANDROID_NDK with +# --android. +# +# Usage: backends/vulkan/test/ops/benchmark_v2/build.sh [--android] +# +# The executable is written to +# /backends/vulkan/test/ops/benchmark_v2/etvk_ops_bench, where the +# build directory is cmake-out, or cmake-android-out with --android. + +set -eux + +BUILD_DIR=cmake-out +PLATFORM_ARGS=() +if [[ "${1:-}" == "--android" ]]; then + BUILD_DIR=cmake-android-out + PLATFORM_ARGS=( + -DCMAKE_TOOLCHAIN_FILE="$ANDROID_NDK/build/cmake/android.toolchain.cmake" + -DANDROID_ABI=arm64-v8a + -DANDROID_PLATFORM=android-28 + ) +fi + +GLSLC_PATH=$(command -v glslc) +JOBS=$(($(nproc 2>/dev/null || sysctl -n hw.ncpu) + 1)) +BENCH_DIR=backends/vulkan/test/ops/benchmark_v2 + +cmake . \ + -DCMAKE_INSTALL_PREFIX="$BUILD_DIR" \ + -DEXECUTORCH_BUILD_VULKAN=ON \ + -DGLSLC_PATH="$GLSLC_PATH" \ + "${PLATFORM_ARGS[@]}" \ + -B"$BUILD_DIR" +cmake --build "$BUILD_DIR" -j"$JOBS" --target install + +cmake "$BENCH_DIR" \ + -DCMAKE_INSTALL_PREFIX="$BUILD_DIR" \ + -DCMAKE_BUILD_TYPE=Release \ + -DGLSLC_PATH="$GLSLC_PATH" \ + "${PLATFORM_ARGS[@]}" \ + -B"$BUILD_DIR/$BENCH_DIR" +cmake --build "$BUILD_DIR/$BENCH_DIR" -j"$JOBS" From e1d5a50fc3aac81725ece2980c4025efe6b6f123 Mon Sep 17 00:00:00 2001 From: Stephen Jia Date: Tue, 3 Nov 2026 11:49:43 -0800 Subject: [PATCH 036/128] [ET-VK][benchmark] Port remaining legacy coverage to benchmark_v2 Pull Request resolved: https://github.com/pytorch/executorch/pull/24520 ## Ulterior Motive Deprecate the legacy operator benchmark (`etvk_bench`) in favor of `benchmark_v2` (`etvk_ops_bench`). This ports the legacy coverage that `benchmark_v2` still lacks, so the legacy benchmark can be removed. ## Rationale **What**: Add `et_vk.conv_with_clamp.default` support and a `masked` direct-route option to `benchmark_v2`, allow unaligned grouped convolutions on the forced FP `direct` route, and port the six legacy sets (`direct_correctness`, `direct_performance`, `dsr_convs_set`, `dsr_convs_comparison`, `dsr_convs_correctness`, `dsr_bias_clamp_correctness`) to JSON. Also port legacy pointwise conv2d and Q8TA pixel shuffle cases that landed after the original JSON migration. **Why**: These sets covered clamped convolution, the masked direct shader path and unaligned grouped convolution, none of which `benchmark_v2` could express. The pointwise and pixel shuffle cases were added to the legacy benchmark only. ## Details - `et_vk.conv_with_clamp.default`: CPU reference (conv2d output clamped to `[output_min, output_max]`), `direct` and `im2col` routes, route inspector, and throughput registration. The FP routes accept the 12-argument form and pass the clamp bounds to production. - `direct` route option `masked: true` maps to production `force_masked`, which disables the border-specialized shader variants. `check_options` now takes the allowed option keys. - Forced FP `direct` with grouped channels not divisible by 4 - **Before**: rejected as unsupported. - **After**: builds production's `conv2d_2x2x4_grouped_gather` path. Q8TA routes keep the restriction. - Ported sets: - `conv2d/cases/correctness.json`: direct (plain and masked), DSR and DSR bias/clamp correctness; 156 new expanded cases (243 total). - `direct_performance.json` and `dsr_performance.json` (new). `dsr_convs_set` is a subset of `dsr_convs_comparison`, so one baseline-vs-`auto` comparison replaces both. - Pointwise conv2d (`pw_correctness`): FP16 variants of every case, plus `[1,33,52,41,47]` and `[1,33,58,63,61]` with bias (partial pixel and channel tiles around the FP16 wide-tile threshold). `dsr_performance.json` gains the nine DSR 1x1 shapes (`dsr_pw_set`). - Inputs use random integers in `[-1, 1]` so FP16 results are exact, replacing the legacy hand-set operands. Unbounded clamps use `FLT_MAX` because JSON has no infinity. - Q8TA pixel shuffle (`pixel_shuffle/q8ta/cases`) - **Before**: `4w4c` and `4c1w` input/output layouts; matching-qparams cases used scale `0.007112` with a one-step tolerance. - **After**: `4w4c`, `4c` and `4c1w` layouts (9 pairs); matching-qparams cases use unit scales and zero tolerance, since passthrough must be exact. Correctness adds `[1,4,5,7]` (padding channel lanes) and `[2,32,6,5]` (batch 2); performance adds `[1,256,S,S]` for S in 16..256. Usage: ```json {"operator": "et_vk.conv_with_clamp.default", "route_sweep": ["direct", {"name": "direct", "options": {"masked": true}}, "im2col"], "args_sweep": [[input, weight, bias, stride, padding, dilation, transposed, output_padding, groups, {"float": -0.25}, {"float": 0.5}, output]]} ``` Authored with Claude Code (AI). ghstack-source-id: 463942934 @exported-using-ghexport Differential Revision: [D129408345](https://our.internmc.facebook.com/intern/diff/D129408345/) --- .../vulkan/test/ops/benchmark_v2/README.md | 5 +- .../framework/test/operator_route_test.cpp | 66 +- .../framework/test/reference_test.cpp | 30 + .../operators/convolution/Conv2dReference.cpp | 20 + .../convolution/Conv2dRouteCommon.cpp | 8 +- .../operators/convolution/Conv2dRouteCommon.h | 4 +- .../operators/convolution/Conv2dRoutes.cpp | 63 +- .../convolution/Conv2dThroughput.cpp | 3 + .../convolution/conv2d/cases/correctness.json | 5588 ++++++++++++++++- .../conv2d/cases/direct_performance.json | 912 +++ .../conv2d/cases/dsr_performance.json | 1757 ++++++ .../pixel_shuffle/q8ta/cases/correctness.json | 302 +- .../pixel_shuffle/q8ta/cases/performance.json | 340 +- 13 files changed, 8721 insertions(+), 377 deletions(-) create mode 100644 backends/vulkan/test/ops/benchmark_v2/operators/convolution/conv2d/cases/direct_performance.json create mode 100644 backends/vulkan/test/ops/benchmark_v2/operators/convolution/conv2d/cases/dsr_performance.json diff --git a/backends/vulkan/test/ops/benchmark_v2/README.md b/backends/vulkan/test/ops/benchmark_v2/README.md index cf00049aeb0..aa6d593c10b 100644 --- a/backends/vulkan/test/ops/benchmark_v2/README.md +++ b/backends/vulkan/test/ops/benchmark_v2/README.md @@ -289,12 +289,11 @@ Registered convolution routes: | Operator | Routes and options | | --- | --- | -| `aten.convolution.default` | `auto`, `direct`, `im2col`. Only `im2col` accepts `intermediate_storage`: `buffer`, `texture2d`, or `texture3d`; omission uses the production choice. | +| `aten.convolution.default`, `et_vk.conv_with_clamp.default` | `auto`, `direct`, `im2col`. `direct` accepts `masked: true`, which disables the border-specialized shader variants. `im2col` accepts `intermediate_storage`: `buffer`, `texture2d`, or `texture3d`; omission uses the production choice. | | `et_vk.q8ta_conv2d.default` | `auto`, `direct` (general convolution), `im2col`. No route options. | The floating-point forced routes currently support forward 2D convolution with -batch 1 and channels-packed texture3d input/output. Direct grouped convolution -requires group channel boundaries aligned to four; im2col requires groups 1. +batch 1 and channels-packed texture3d input/output. im2col requires groups 1. The Q8TA routes use packed int8 buffers and the production streaming feasibility checks. Route eligibility is separate from automatic performance heuristics: for example, forcing floating-point im2col can test dilated convolutions even diff --git a/backends/vulkan/test/ops/benchmark_v2/framework/test/operator_route_test.cpp b/backends/vulkan/test/ops/benchmark_v2/framework/test/operator_route_test.cpp index 29dbb35d8e5..ecf41805fd6 100644 --- a/backends/vulkan/test/ops/benchmark_v2/framework/test/operator_route_test.cpp +++ b/backends/vulkan/test/ops/benchmark_v2/framework/test/operator_route_test.cpp @@ -192,13 +192,58 @@ TEST_F( UnsupportedRoute); } -TEST_F(BenchmarkOperatorRoute, ForcedDirectSupportsAlignedGroupedConvolution) { - FpConvCase test( - vkapi::kFloat, utils::kTexture3D, utils::kChannelsPacked, 1, 8, 2); - EXPECT_EQ( - build_conv2d_direct(test.graph, test.args, Json::object()).at("name"), - "direct"); +TEST_F(BenchmarkOperatorRoute, ForcedDirectSupportsGroupedConvolution) { + // 16 channels in 4 groups give output groups of 2, which cross texels. + for (const int64_t channels : {8, 16}) { + FpConvCase test( + vkapi::kFloat, + utils::kTexture3D, + utils::kChannelsPacked, + 1, + channels, + channels / 4); + EXPECT_EQ( + build_conv2d_direct(test.graph, test.args, Json::object()).at("name"), + "direct"); + EXPECT_EQ(test.graph.execute_nodes().size(), 1); + } +} + +TEST_F(BenchmarkOperatorRoute, ForcedDirectHonorsMaskedOption) { + FpConvCase test; + const Json selected = + build_conv2d_direct(test.graph, test.args, {{"masked", true}}); + EXPECT_EQ(selected.at("name"), "direct"); + EXPECT_EQ(selected.at("options"), Json({{"masked", true}})); EXPECT_EQ(test.graph.execute_nodes().size(), 1); + FpConvCase invalid; + EXPECT_THROW( + build_conv2d_direct(invalid.graph, invalid.args, {{"masked", 1}}), + std::invalid_argument); + EXPECT_THROW( + build_conv2d_im2col(invalid.graph, invalid.args, {{"masked", true}}), + std::invalid_argument); + EXPECT_TRUE(invalid.graph.execute_nodes().empty()); +} + +TEST_F(BenchmarkOperatorRoute, ClampedConvolutionBuildsBothRoutes) { + for (const bool im2col : {false, true}) { + FpConvCase test; + test.args.insert( + test.args.begin() + 9, + {test.graph.add_scalar(-0.25), test.graph.add_scalar(0.5)}); + const Json selected = im2col + ? build_conv2d_im2col(test.graph, test.args, Json::object()) + : build_conv2d_direct(test.graph, test.args, Json::object()); + EXPECT_EQ(selected.at("name"), im2col ? "im2col" : "direct"); + EXPECT_EQ(test.graph.execute_nodes().size(), im2col ? 2 : 1); + } + FpConvCase wrong_count; + wrong_count.args.insert( + wrong_count.args.begin() + 9, wrong_count.graph.add_scalar(0.5)); + EXPECT_THROW( + build_conv2d_direct(wrong_count.graph, wrong_count.args, Json::object()), + std::invalid_argument); } TEST_F(BenchmarkOperatorRoute, UnknownOptionsAndGpuWeightsAreRejected) { @@ -324,12 +369,9 @@ TEST_F( EXPECT_TRUE(test.graph.prepack_nodes().empty()); } -TEST_F(BenchmarkOperatorRoute, GroupedRoutesRejectOutputBlocksCrossingGroups) { - FpConvCase fp( - vkapi::kFloat, utils::kTexture3D, utils::kChannelsPacked, 1, 16, 4); - EXPECT_THROW( - build_conv2d_direct(fp.graph, fp.args, Json::object()), UnsupportedRoute); - EXPECT_TRUE(fp.graph.prepack_nodes().empty()); +TEST_F( + BenchmarkOperatorRoute, + Q8GroupedRoutesRejectOutputBlocksCrossingGroups) { Q8ConvCase q8(1, 16, 4); EXPECT_THROW( build_q8ta_conv2d_direct(q8.graph, q8.args, Json::object()), diff --git a/backends/vulkan/test/ops/benchmark_v2/framework/test/reference_test.cpp b/backends/vulkan/test/ops/benchmark_v2/framework/test/reference_test.cpp index 40eb28e1e02..f3ed234e82a 100644 --- a/backends/vulkan/test/ops/benchmark_v2/framework/test/reference_test.cpp +++ b/backends/vulkan/test/ops/benchmark_v2/framework/test/reference_test.cpp @@ -168,6 +168,36 @@ TEST(BenchmarkReference, GroupedConvolutionHonorsStridePaddingAndDilation) { EXPECT_EQ(result.at("y"), (std::vector{5, 11, 5, 19, 19, -41})); } +TEST(BenchmarkReference, ConvWithClampClampsConvolutionOutput) { + const Json spec = { + {"operator", "et_vk.conv_with_clamp.default"}, + {"args", + {{{"tensor", "x"}}, + {{"tensor", "w"}}, + {{"tensor", "b"}}, + {{"int_list", {1, 1}}}, + {{"int_list", {0, 0}}}, + {{"int_list", {1, 1}}}, + {{"bool", false}}, + {{"int_list", {0, 0}}}, + {{"int", 1}}, + {{"float", -0.25}}, + {{"float", 0.5}}, + {{"tensor", "y"}}}}, + {"tensors", + {{"x", tensor({1, 1, 1, 3})}, + {"w", tensor({1, 1, 1, 1})}, + {"b", tensor({1})}, + {"y", tensor({1, 1, 1, 3})}}}}; + const auto result = reference_outputs( + spec, + {}, + {{"x", bytes({-1, 0.25, 2})}, + {"w", bytes({1})}, + {"b", bytes({0})}}); + EXPECT_EQ(result.at("y"), (std::vector{-0.25, 0.25, 0.5})); +} + TEST(BenchmarkReference, Q4LinearUnpacksNibblesAndUsesPerGroupScales) { const Json spec = { {"operator", "et_vk.q4gsw_linear.default"}, diff --git a/backends/vulkan/test/ops/benchmark_v2/operators/convolution/Conv2dReference.cpp b/backends/vulkan/test/ops/benchmark_v2/operators/convolution/Conv2dReference.cpp index df865939107..16907228438 100644 --- a/backends/vulkan/test/ops/benchmark_v2/operators/convolution/Conv2dReference.cpp +++ b/backends/vulkan/test/ops/benchmark_v2/operators/convolution/Conv2dReference.cpp @@ -9,6 +9,7 @@ #include #include +#include #include namespace vkcompute::shader_profiling::reference { @@ -60,9 +61,28 @@ conv2d_reference(const Json& spec, const TensorMap& tensors, size_t threads) { }); } +namespace { + +Tensor conv_with_clamp_reference( + const Json& spec, + const TensorMap& tensors, + size_t threads) { + const float min = spec.at("args").at(9).at("float"); + const float max = spec.at("args").at(10).at("float"); + Tensor output = conv2d_reference(spec, tensors, threads); + for (double& value : output.values) { + value = std::clamp(float(value), min, max); + } + return output; +} + +} // namespace + REGISTER_REFERENCE_FUNCTIONS { REGISTER_REFERENCE_FN(conv2d, conv2d_reference); + REGISTER_REFERENCE_FN(conv_with_clamp, conv_with_clamp_reference); REGISTER_OPERATOR_REFERENCE(aten.convolution.default, conv2d); + REGISTER_OPERATOR_REFERENCE(et_vk.conv_with_clamp.default, conv_with_clamp); } } // namespace vkcompute::shader_profiling::reference diff --git a/backends/vulkan/test/ops/benchmark_v2/operators/convolution/Conv2dRouteCommon.cpp b/backends/vulkan/test/ops/benchmark_v2/operators/convolution/Conv2dRouteCommon.cpp index ab9cb74b1c5..70df8256e8e 100644 --- a/backends/vulkan/test/ops/benchmark_v2/operators/convolution/Conv2dRouteCommon.cpp +++ b/backends/vulkan/test/ops/benchmark_v2/operators/convolution/Conv2dRouteCommon.cpp @@ -6,6 +6,7 @@ #include +#include #include namespace vkcompute::shader_profiling::convolution { @@ -16,12 +17,15 @@ void supported(bool condition, const std::string& message) { } } -void check_options(const Json& options, bool intermediate_storage) { +void check_options( + const Json& options, + const std::vector& allowed) { if (!options.is_object()) { throw std::invalid_argument("Route options must be an object"); } for (const auto& item : options.items()) { - if (!intermediate_storage || item.key() != "intermediate_storage") { + if (std::find(allowed.begin(), allowed.end(), item.key()) == + allowed.end()) { throw std::invalid_argument( "Unknown convolution route option: " + item.key()); } diff --git a/backends/vulkan/test/ops/benchmark_v2/operators/convolution/Conv2dRouteCommon.h b/backends/vulkan/test/ops/benchmark_v2/operators/convolution/Conv2dRouteCommon.h index 947372b14db..d3d91bd7e7a 100644 --- a/backends/vulkan/test/ops/benchmark_v2/operators/convolution/Conv2dRouteCommon.h +++ b/backends/vulkan/test/ops/benchmark_v2/operators/convolution/Conv2dRouteCommon.h @@ -20,7 +20,9 @@ struct ConvShape { }; void supported(bool condition, const std::string& message); -void check_options(const Json& options, bool intermediate_storage = false); +void check_options( + const Json& options, + const std::vector& allowed = {}); std::array spatial_parameter(ComputeGraph& graph, ValueRef value, bool positive); void constant_tensor( diff --git a/backends/vulkan/test/ops/benchmark_v2/operators/convolution/Conv2dRoutes.cpp b/backends/vulkan/test/ops/benchmark_v2/operators/convolution/Conv2dRoutes.cpp index 48da7d5d6ce..29a14a893c1 100644 --- a/backends/vulkan/test/ops/benchmark_v2/operators/convolution/Conv2dRoutes.cpp +++ b/backends/vulkan/test/ops/benchmark_v2/operators/convolution/Conv2dRoutes.cpp @@ -18,7 +18,7 @@ using namespace convolution; namespace { std::optional storage_option(const Json& options) { - check_options(options, true); + check_options(options, {"intermediate_storage"}); if (!options.contains("intermediate_storage")) { return std::nullopt; } @@ -55,8 +55,36 @@ bool floating(vkapi::ScalarType dtype) { return dtype == vkapi::kFloat || dtype == vkapi::kHalf; } +bool masked_option(const Json& options) { + check_options(options, {"masked"}); + if (!options.contains("masked")) { + return false; + } + if (!options.at("masked").is_boolean()) { + throw std::invalid_argument("masked must be a boolean"); + } + return options.at("masked").get(); +} + +// conv_with_clamp passes output_min and output_max before the output. +struct Clamp { + bool enabled = false; + float min = 0; + float max = 0; +}; + +Clamp clamp_args(ComputeGraph& graph, const std::vector& args) { + if (args.size() == 10u) { + return {}; + } + return { + true, + graph.extract_scalar(args[9]), + graph.extract_scalar(args[10])}; +} + ConvShape conv_shape(ComputeGraph& graph, const std::vector& args) { - if (args.size() != 10u) + if (args.size() != 10u && args.size() != 12u) throw std::invalid_argument( "Convolution route received wrong argument count"); const ValueRef input = args[0]; @@ -143,9 +171,9 @@ Json build_conv2d_direct( ComputeGraph& graph, const std::vector& args, const Json& options) { - check_options(options); - const ConvShape shape = conv_shape(graph, args); - grouped_packing(shape); + const bool masked = masked_option(options); + conv_shape(graph, args); + const Clamp clamp = clamp_args(graph, args); conv2d_direct_impl( graph, args[0], @@ -157,11 +185,12 @@ Json build_conv2d_direct( args[6], args[7], args[8], - args[9], - false, - 0, - 0); - return selected("direct"); + args.back(), + clamp.enabled, + clamp.min, + clamp.max, + masked); + return masked ? selected("direct", {{"masked", true}}) : selected("direct"); } Json build_conv2d_im2col( @@ -172,6 +201,7 @@ Json build_conv2d_im2col( const ConvShape shape = conv_shape(graph, args); supported(shape.groups == 1, "FP im2col convolution requires groups 1"); check_fp_im2col_buffer_resources(graph, args[0], shape, storage); + const Clamp clamp = clamp_args(graph, args); const size_t first = graph.execute_nodes().size(); conv2d_gemm_impl( graph, @@ -181,10 +211,10 @@ Json build_conv2d_im2col( args[3], args[4], args[5], - args[9], - false, - 0, - 0, + args.back(), + clamp.enabled, + clamp.min, + clamp.max, storage); const ValueRef scratch = graph.execute_nodes().at(first)->args().at(0).refs.at(0); @@ -230,6 +260,11 @@ REGISTER_OPERATOR_ROUTES { REGISTER_OPERATOR_ROUTE( aten.convolution.default, im2col, build_conv2d_im2col); REGISTER_ROUTE_INSPECTOR(aten.convolution.default, inspect_conv2d_route); + REGISTER_OPERATOR_ROUTE( + et_vk.conv_with_clamp.default, direct, build_conv2d_direct); + REGISTER_OPERATOR_ROUTE( + et_vk.conv_with_clamp.default, im2col, build_conv2d_im2col); + REGISTER_ROUTE_INSPECTOR(et_vk.conv_with_clamp.default, inspect_conv2d_route); } } // namespace vkcompute::shader_profiling diff --git a/backends/vulkan/test/ops/benchmark_v2/operators/convolution/Conv2dThroughput.cpp b/backends/vulkan/test/ops/benchmark_v2/operators/convolution/Conv2dThroughput.cpp index b52a03eb307..bf386a3b263 100644 --- a/backends/vulkan/test/ops/benchmark_v2/operators/convolution/Conv2dThroughput.cpp +++ b/backends/vulkan/test/ops/benchmark_v2/operators/convolution/Conv2dThroughput.cpp @@ -37,6 +37,9 @@ REGISTER_THROUGHPUT_FUNCTIONS { REGISTER_OPERATOR_MEMORY_TRAFFIC( aten.convolution.default, throughput::tensor_traffic); REGISTER_OPERATOR_COMPUTE_FLOPS(aten.convolution.default, conv2d_flops); + REGISTER_OPERATOR_MEMORY_TRAFFIC( + et_vk.conv_with_clamp.default, throughput::tensor_traffic); + REGISTER_OPERATOR_COMPUTE_FLOPS(et_vk.conv_with_clamp.default, conv2d_flops); REGISTER_SHADER_MEMORY_TRAFFIC(conv2d, throughput::tensor_traffic); } diff --git a/backends/vulkan/test/ops/benchmark_v2/operators/convolution/conv2d/cases/correctness.json b/backends/vulkan/test/ops/benchmark_v2/operators/convolution/conv2d/cases/correctness.json index a0c91bb7334..bc7414f3d0f 100644 --- a/backends/vulkan/test/ops/benchmark_v2/operators/convolution/conv2d/cases/correctness.json +++ b/backends/vulkan/test/ops/benchmark_v2/operators/convolution/conv2d/cases/correctness.json @@ -1535,7 +1535,7 @@ ] }, { - "name": "conv2d pw_correctness [1, 32, 16, 8, 8, False] float32 auto", + "name": "conv2d pw_correctness [1, 16, 32, 8, 8, False] float16 auto", "operator": "aten.convolution.default", "route": "auto", "target_ms": 100, @@ -1544,16 +1544,16 @@ "max_dispatches": 200, "validation_method": { "function": "is_close", - "atol": 0.001, - "rtol": 0.001 + "atol": 0.1, + "rtol": 0.01 }, "args_sweep": [ [ { "tensor": { "name": "input", - "sizes": [1, 32, 8, 8], - "dtype": "float32", + "sizes": [1, 16, 8, 8], + "dtype": "float16", "storage": "texture3d", "layout": "channels", "data": { @@ -1564,8 +1564,8 @@ { "tensor": { "name": "weight", - "sizes": [16, 32, 1, 1], - "dtype": "float32", + "sizes": [32, 16, 1, 1], + "dtype": "float16", "storage": "constant", "layout": "channels", "data": { @@ -1595,8 +1595,8 @@ { "tensor": { "name": "output", - "sizes": [1, 16, 8, 8], - "dtype": "float32", + "sizes": [1, 32, 8, 8], + "dtype": "float16", "storage": "texture3d", "layout": "channels", "data": { @@ -1608,7 +1608,7 @@ ] }, { - "name": "conv2d pw_correctness [1, 16, 32, 8, 8, True] float32 auto", + "name": "conv2d pw_correctness [1, 32, 16, 8, 8, False] float32 auto", "operator": "aten.convolution.default", "route": "auto", "target_ms": 100, @@ -1625,7 +1625,7 @@ { "tensor": { "name": "input", - "sizes": [1, 16, 8, 8], + "sizes": [1, 32, 8, 8], "dtype": "float32", "storage": "texture3d", "layout": "channels", @@ -1637,19 +1637,7 @@ { "tensor": { "name": "weight", - "sizes": [32, 16, 1, 1], - "dtype": "float32", - "storage": "constant", - "layout": "channels", - "data": { - "rand_uniform_float": [-1, 1] - } - } - }, - { - "tensor": { - "name": "bias", - "sizes": [32], + "sizes": [16, 32, 1, 1], "dtype": "float32", "storage": "constant", "layout": "channels", @@ -1658,6 +1646,7 @@ } } }, + null, { "int_list": [1, 1] }, @@ -1679,7 +1668,7 @@ { "tensor": { "name": "output", - "sizes": [1, 32, 8, 8], + "sizes": [1, 16, 8, 8], "dtype": "float32", "storage": "texture3d", "layout": "channels", @@ -1692,7 +1681,7 @@ ] }, { - "name": "conv2d pw_correctness [1, 13, 27, 8, 8, False] float32 auto", + "name": "conv2d pw_correctness [1, 32, 16, 8, 8, False] float16 auto", "operator": "aten.convolution.default", "route": "auto", "target_ms": 100, @@ -1701,16 +1690,16 @@ "max_dispatches": 200, "validation_method": { "function": "is_close", - "atol": 0.001, - "rtol": 0.001 + "atol": 0.1, + "rtol": 0.01 }, "args_sweep": [ [ { "tensor": { "name": "input", - "sizes": [1, 13, 8, 8], - "dtype": "float32", + "sizes": [1, 32, 8, 8], + "dtype": "float16", "storage": "texture3d", "layout": "channels", "data": { @@ -1721,8 +1710,8 @@ { "tensor": { "name": "weight", - "sizes": [27, 13, 1, 1], - "dtype": "float32", + "sizes": [16, 32, 1, 1], + "dtype": "float16", "storage": "constant", "layout": "channels", "data": { @@ -1752,8 +1741,8 @@ { "tensor": { "name": "output", - "sizes": [1, 27, 8, 8], - "dtype": "float32", + "sizes": [1, 16, 8, 8], + "dtype": "float16", "storage": "texture3d", "layout": "channels", "data": { @@ -1765,7 +1754,7 @@ ] }, { - "name": "conv2d pw_correctness [1, 33, 17, 8, 8, False] float32 auto", + "name": "conv2d pw_correctness [1, 16, 32, 8, 8, True] float32 auto", "operator": "aten.convolution.default", "route": "auto", "target_ms": 100, @@ -1782,7 +1771,7 @@ { "tensor": { "name": "input", - "sizes": [1, 33, 8, 8], + "sizes": [1, 16, 8, 8], "dtype": "float32", "storage": "texture3d", "layout": "channels", @@ -1794,7 +1783,19 @@ { "tensor": { "name": "weight", - "sizes": [17, 33, 1, 1], + "sizes": [32, 16, 1, 1], + "dtype": "float32", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [32], "dtype": "float32", "storage": "constant", "layout": "channels", @@ -1803,7 +1804,6 @@ } } }, - null, { "int_list": [1, 1] }, @@ -1825,7 +1825,7 @@ { "tensor": { "name": "output", - "sizes": [1, 17, 8, 8], + "sizes": [1, 32, 8, 8], "dtype": "float32", "storage": "texture3d", "layout": "channels", @@ -1838,25 +1838,25 @@ ] }, { - "name": "conv2d dw_correctness [[1, 8, 16, 16], [3, 3], [1, 1], [1, 1], [1, 1], False] float32 auto", + "name": "conv2d pw_correctness [1, 16, 32, 8, 8, True] float16 auto", "operator": "aten.convolution.default", + "route": "auto", "target_ms": 100, "warmup_runs": 1, "benchmark_runs": 1, "max_dispatches": 200, "validation_method": { "function": "is_close", - "atol": 0.001, - "rtol": 0.001 + "atol": 0.1, + "rtol": 0.01 }, - "route": "auto", "args_sweep": [ [ { "tensor": { "name": "input", - "sizes": [1, 8, 16, 16], - "dtype": "float32", + "sizes": [1, 16, 8, 8], + "dtype": "float16", "storage": "texture3d", "layout": "channels", "data": { @@ -1867,8 +1867,8 @@ { "tensor": { "name": "weight", - "sizes": [8, 1, 3, 3], - "dtype": "float32", + "sizes": [32, 16, 1, 1], + "dtype": "float16", "storage": "constant", "layout": "channels", "data": { @@ -1876,13 +1876,24 @@ } } }, - null, { - "int_list": [1, 1] + "tensor": { + "name": "bias", + "sizes": [32], + "dtype": "float16", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } }, { "int_list": [1, 1] }, + { + "int_list": [0, 0] + }, { "int_list": [1, 1] }, @@ -1893,13 +1904,13 @@ "int_list": [0, 0] }, { - "int": 8 + "int": 1 }, { "tensor": { "name": "output", - "sizes": [1, 8, 16, 16], - "dtype": "float32", + "sizes": [1, 32, 8, 8], + "dtype": "float16", "storage": "texture3d", "layout": "channels", "data": { @@ -1911,8 +1922,9 @@ ] }, { - "name": "conv2d dw_correctness [[1, 11, 16, 16], [3, 3], [1, 1], [1, 1], [1, 1], False] float32 auto", + "name": "conv2d pw_correctness [1, 13, 27, 8, 8, False] float32 auto", "operator": "aten.convolution.default", + "route": "auto", "target_ms": 100, "warmup_runs": 1, "benchmark_runs": 1, @@ -1922,13 +1934,12 @@ "atol": 0.001, "rtol": 0.001 }, - "route": "auto", "args_sweep": [ [ { "tensor": { "name": "input", - "sizes": [1, 11, 16, 16], + "sizes": [1, 13, 8, 8], "dtype": "float32", "storage": "texture3d", "layout": "channels", @@ -1940,7 +1951,7 @@ { "tensor": { "name": "weight", - "sizes": [11, 1, 3, 3], + "sizes": [27, 13, 1, 1], "dtype": "float32", "storage": "constant", "layout": "channels", @@ -1954,7 +1965,7 @@ "int_list": [1, 1] }, { - "int_list": [1, 1] + "int_list": [0, 0] }, { "int_list": [1, 1] @@ -1966,12 +1977,12 @@ "int_list": [0, 0] }, { - "int": 11 + "int": 1 }, { "tensor": { "name": "output", - "sizes": [1, 11, 16, 16], + "sizes": [1, 27, 8, 8], "dtype": "float32", "storage": "texture3d", "layout": "channels", @@ -1984,25 +1995,25 @@ ] }, { - "name": "conv2d dw_correctness [[1, 3, 16, 16], [3, 3], [1, 1], [1, 1], [1, 1], False] float32 auto", + "name": "conv2d pw_correctness [1, 13, 27, 8, 8, False] float16 auto", "operator": "aten.convolution.default", + "route": "auto", "target_ms": 100, "warmup_runs": 1, "benchmark_runs": 1, "max_dispatches": 200, "validation_method": { "function": "is_close", - "atol": 0.001, - "rtol": 0.001 + "atol": 0.1, + "rtol": 0.01 }, - "route": "auto", "args_sweep": [ [ { "tensor": { "name": "input", - "sizes": [1, 3, 16, 16], - "dtype": "float32", + "sizes": [1, 13, 8, 8], + "dtype": "float16", "storage": "texture3d", "layout": "channels", "data": { @@ -2013,8 +2024,8 @@ { "tensor": { "name": "weight", - "sizes": [3, 1, 3, 3], - "dtype": "float32", + "sizes": [27, 13, 1, 1], + "dtype": "float16", "storage": "constant", "layout": "channels", "data": { @@ -2027,7 +2038,7 @@ "int_list": [1, 1] }, { - "int_list": [1, 1] + "int_list": [0, 0] }, { "int_list": [1, 1] @@ -2039,13 +2050,13 @@ "int_list": [0, 0] }, { - "int": 3 + "int": 1 }, { "tensor": { "name": "output", - "sizes": [1, 3, 16, 16], - "dtype": "float32", + "sizes": [1, 27, 8, 8], + "dtype": "float16", "storage": "texture3d", "layout": "channels", "data": { @@ -2057,8 +2068,9 @@ ] }, { - "name": "conv2d dw_correctness [[1, 8, 16, 16], [5, 5], [1, 1], [2, 2], [1, 1], False] float32 auto", + "name": "conv2d pw_correctness [1, 33, 17, 8, 8, False] float32 auto", "operator": "aten.convolution.default", + "route": "auto", "target_ms": 100, "warmup_runs": 1, "benchmark_runs": 1, @@ -2068,13 +2080,12 @@ "atol": 0.001, "rtol": 0.001 }, - "route": "auto", "args_sweep": [ [ { "tensor": { "name": "input", - "sizes": [1, 8, 16, 16], + "sizes": [1, 33, 8, 8], "dtype": "float32", "storage": "texture3d", "layout": "channels", @@ -2086,7 +2097,7 @@ { "tensor": { "name": "weight", - "sizes": [8, 1, 5, 5], + "sizes": [17, 33, 1, 1], "dtype": "float32", "storage": "constant", "layout": "channels", @@ -2100,7 +2111,7 @@ "int_list": [1, 1] }, { - "int_list": [2, 2] + "int_list": [0, 0] }, { "int_list": [1, 1] @@ -2112,12 +2123,12 @@ "int_list": [0, 0] }, { - "int": 8 + "int": 1 }, { "tensor": { "name": "output", - "sizes": [1, 8, 16, 16], + "sizes": [1, 17, 8, 8], "dtype": "float32", "storage": "texture3d", "layout": "channels", @@ -2130,31 +2141,25 @@ ] }, { - "name": "conv2d dw_correctness [[1, 8, 16, 16],", + "name": "conv2d pw_correctness [1, 33, 17, 8, 8, False] float16 auto", "operator": "aten.convolution.default", + "route": "auto", "target_ms": 100, "warmup_runs": 1, "benchmark_runs": 1, "max_dispatches": 200, "validation_method": { "function": "is_close", - "atol": 0.001, - "rtol": 0.001 - }, - "route": "auto", - "sweep_variables": { - "v_WEIGHT_SIZES": [ - [8, 1, 3, 3], - [8, 1, 4, 4] - ] + "atol": 0.1, + "rtol": 0.01 }, "args_sweep": [ [ { "tensor": { "name": "input", - "sizes": [1, 8, 16, 16], - "dtype": "float32", + "sizes": [1, 33, 8, 8], + "dtype": "float16", "storage": "texture3d", "layout": "channels", "data": { @@ -2165,8 +2170,8 @@ { "tensor": { "name": "weight", - "sizes": "v_WEIGHT_SIZES", - "dtype": "float32", + "sizes": [17, 33, 1, 1], + "dtype": "float16", "storage": "constant", "layout": "channels", "data": { @@ -2176,10 +2181,10 @@ }, null, { - "int_list": [2, 2] + "int_list": [1, 1] }, { - "int_list": [1, 1] + "int_list": [0, 0] }, { "int_list": [1, 1] @@ -2191,13 +2196,13 @@ "int_list": [0, 0] }, { - "int": 8 + "int": 1 }, { "tensor": { "name": "output", - "sizes": [1, 8, 8, 8], - "dtype": "float32", + "sizes": [1, 17, 8, 8], + "dtype": "float16", "storage": "texture3d", "layout": "channels", "data": { @@ -2209,8 +2214,9 @@ ] }, { - "name": "conv2d dw_correctness [[1, 8, 32, 32], [6, 6], [3, 3], [2, 2], [3, 3], False] float32 auto", + "name": "conv2d pw_correctness [1, 33, 52, 41, 47, True] float32 auto", "operator": "aten.convolution.default", + "route": "auto", "target_ms": 100, "warmup_runs": 1, "benchmark_runs": 1, @@ -2220,13 +2226,12 @@ "atol": 0.001, "rtol": 0.001 }, - "route": "auto", "args_sweep": [ [ { "tensor": { "name": "input", - "sizes": [1, 8, 32, 32], + "sizes": [1, 33, 41, 47], "dtype": "float32", "storage": "texture3d", "layout": "channels", @@ -2238,7 +2243,7 @@ { "tensor": { "name": "weight", - "sizes": [8, 1, 6, 6], + "sizes": [52, 33, 1, 1], "dtype": "float32", "storage": "constant", "layout": "channels", @@ -2247,15 +2252,26 @@ } } }, - null, { - "int_list": [3, 3] + "tensor": { + "name": "bias", + "sizes": [52], + "dtype": "float32", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } }, { - "int_list": [2, 2] + "int_list": [1, 1] }, { - "int_list": [3, 3] + "int_list": [0, 0] + }, + { + "int_list": [1, 1] }, { "bool": false @@ -2264,12 +2280,12 @@ "int_list": [0, 0] }, { - "int": 8 + "int": 1 }, { "tensor": { "name": "output", - "sizes": [1, 8, 7, 7], + "sizes": [1, 52, 41, 47], "dtype": "float32", "storage": "texture3d", "layout": "channels", @@ -2282,25 +2298,25 @@ ] }, { - "name": "conv2d dw_correctness [[1, 8, 16, 16], [3, 3], [1, 1], [1, 1], [1, 1], True] float32 auto", + "name": "conv2d pw_correctness [1, 33, 52, 41, 47, True] float16 auto", "operator": "aten.convolution.default", + "route": "auto", "target_ms": 100, "warmup_runs": 1, "benchmark_runs": 1, "max_dispatches": 200, "validation_method": { "function": "is_close", - "atol": 0.001, - "rtol": 0.001 + "atol": 0.1, + "rtol": 0.01 }, - "route": "auto", "args_sweep": [ [ { "tensor": { "name": "input", - "sizes": [1, 8, 16, 16], - "dtype": "float32", + "sizes": [1, 33, 41, 47], + "dtype": "float16", "storage": "texture3d", "layout": "channels", "data": { @@ -2311,8 +2327,8 @@ { "tensor": { "name": "weight", - "sizes": [8, 1, 3, 3], - "dtype": "float32", + "sizes": [52, 33, 1, 1], + "dtype": "float16", "storage": "constant", "layout": "channels", "data": { @@ -2323,8 +2339,8 @@ { "tensor": { "name": "bias", - "sizes": [8], - "dtype": "float32", + "sizes": [52], + "dtype": "float16", "storage": "constant", "layout": "channels", "data": { @@ -2336,7 +2352,7 @@ "int_list": [1, 1] }, { - "int_list": [1, 1] + "int_list": [0, 0] }, { "int_list": [1, 1] @@ -2348,13 +2364,13 @@ "int_list": [0, 0] }, { - "int": 8 + "int": 1 }, { "tensor": { "name": "output", - "sizes": [1, 8, 16, 16], - "dtype": "float32", + "sizes": [1, 52, 41, 47], + "dtype": "float16", "storage": "texture3d", "layout": "channels", "data": { @@ -2366,8 +2382,9 @@ ] }, { - "name": "conv2d dw_correctness [[1, 8, 32, 32], [7, 7], [1, 1], [6, 6], [2, 2], True] float32 auto", + "name": "conv2d pw_correctness [1, 33, 58, 63, 61, True] float32 auto", "operator": "aten.convolution.default", + "route": "auto", "target_ms": 100, "warmup_runs": 1, "benchmark_runs": 1, @@ -2377,13 +2394,12 @@ "atol": 0.001, "rtol": 0.001 }, - "route": "auto", "args_sweep": [ [ { "tensor": { "name": "input", - "sizes": [1, 8, 32, 32], + "sizes": [1, 33, 63, 61], "dtype": "float32", "storage": "texture3d", "layout": "channels", @@ -2395,7 +2411,7 @@ { "tensor": { "name": "weight", - "sizes": [8, 1, 7, 7], + "sizes": [58, 33, 1, 1], "dtype": "float32", "storage": "constant", "layout": "channels", @@ -2407,7 +2423,7 @@ { "tensor": { "name": "bias", - "sizes": [8], + "sizes": [58], "dtype": "float32", "storage": "constant", "layout": "channels", @@ -2420,10 +2436,10 @@ "int_list": [1, 1] }, { - "int_list": [6, 6] + "int_list": [0, 0] }, { - "int_list": [2, 2] + "int_list": [1, 1] }, { "bool": false @@ -2432,12 +2448,12 @@ "int_list": [0, 0] }, { - "int": 8 + "int": 1 }, { "tensor": { "name": "output", - "sizes": [1, 8, 32, 32], + "sizes": [1, 58, 63, 61], "dtype": "float32", "storage": "texture3d", "layout": "channels", @@ -2450,25 +2466,25 @@ ] }, { - "name": "conv2d dw_correctness [[1, 8, 31, 37], [3, 5], [2, 1], [1, 2], [1, 1], True] float32 auto", + "name": "conv2d pw_correctness [1, 33, 58, 63, 61, True] float16 auto", "operator": "aten.convolution.default", + "route": "auto", "target_ms": 100, "warmup_runs": 1, "benchmark_runs": 1, "max_dispatches": 200, "validation_method": { "function": "is_close", - "atol": 0.001, - "rtol": 0.001 + "atol": 0.1, + "rtol": 0.01 }, - "route": "auto", "args_sweep": [ [ { "tensor": { "name": "input", - "sizes": [1, 8, 31, 37], - "dtype": "float32", + "sizes": [1, 33, 63, 61], + "dtype": "float16", "storage": "texture3d", "layout": "channels", "data": { @@ -2479,8 +2495,8 @@ { "tensor": { "name": "weight", - "sizes": [8, 1, 3, 5], - "dtype": "float32", + "sizes": [58, 33, 1, 1], + "dtype": "float16", "storage": "constant", "layout": "channels", "data": { @@ -2491,8 +2507,8 @@ { "tensor": { "name": "bias", - "sizes": [8], - "dtype": "float32", + "sizes": [58], + "dtype": "float16", "storage": "constant", "layout": "channels", "data": { @@ -2501,10 +2517,10 @@ } }, { - "int_list": [2, 1] + "int_list": [1, 1] }, { - "int_list": [1, 2] + "int_list": [0, 0] }, { "int_list": [1, 1] @@ -2516,13 +2532,13 @@ "int_list": [0, 0] }, { - "int": 8 + "int": 1 }, { "tensor": { "name": "output", - "sizes": [1, 8, 16, 37], - "dtype": "float32", + "sizes": [1, 58, 63, 61], + "dtype": "float16", "storage": "texture3d", "layout": "channels", "data": { @@ -2534,28 +2550,25 @@ ] }, { - "name": "conv2d performance [[1, 64, 36, 48], 32, [3, 3], [1, 1], [1, 1], [1, 1], False] float16", + "name": "conv2d dw_correctness [[1, 8, 16, 16], [3, 3], [1, 1], [1, 1], [1, 1], False] float32 auto", "operator": "aten.convolution.default", "target_ms": 100, - "warmup_runs": 5, - "benchmark_runs": 20, + "warmup_runs": 1, + "benchmark_runs": 1, "max_dispatches": 200, "validation_method": { "function": "is_close", - "atol": 1.0, - "rtol": 0.01 - }, - "route": "v_ROUTE", - "sweep_variables": { - "v_ROUTE": ["auto"] + "atol": 0.001, + "rtol": 0.001 }, + "route": "auto", "args_sweep": [ [ { "tensor": { "name": "input", - "sizes": [1, 64, 36, 48], - "dtype": "float16", + "sizes": [1, 8, 16, 16], + "dtype": "float32", "storage": "texture3d", "layout": "channels", "data": { @@ -2566,8 +2579,8 @@ { "tensor": { "name": "weight", - "sizes": [32, 64, 3, 3], - "dtype": "float16", + "sizes": [8, 1, 3, 3], + "dtype": "float32", "storage": "constant", "layout": "channels", "data": { @@ -2592,13 +2605,13 @@ "int_list": [0, 0] }, { - "int": 1 + "int": 8 }, { "tensor": { "name": "output", - "sizes": [1, 32, 36, 48], - "dtype": "float16", + "sizes": [1, 8, 16, 16], + "dtype": "float32", "storage": "texture3d", "layout": "channels", "data": { @@ -2610,27 +2623,24 @@ ] }, { - "name": "conv2d performance [[1, 64, 36, 48], 32, [3, 3], [1, 1], [1, 1], [1, 1], False] float32", + "name": "conv2d dw_correctness [[1, 11, 16, 16], [3, 3], [1, 1], [1, 1], [1, 1], False] float32 auto", "operator": "aten.convolution.default", "target_ms": 100, - "warmup_runs": 5, - "benchmark_runs": 20, + "warmup_runs": 1, + "benchmark_runs": 1, "max_dispatches": 200, "validation_method": { "function": "is_close", "atol": 0.001, "rtol": 0.001 }, - "route": "v_ROUTE", - "sweep_variables": { - "v_ROUTE": ["auto"] - }, + "route": "auto", "args_sweep": [ [ { "tensor": { "name": "input", - "sizes": [1, 64, 36, 48], + "sizes": [1, 11, 16, 16], "dtype": "float32", "storage": "texture3d", "layout": "channels", @@ -2642,7 +2652,7 @@ { "tensor": { "name": "weight", - "sizes": [32, 64, 3, 3], + "sizes": [11, 1, 3, 3], "dtype": "float32", "storage": "constant", "layout": "channels", @@ -2668,12 +2678,12 @@ "int_list": [0, 0] }, { - "int": 1 + "int": 11 }, { "tensor": { "name": "output", - "sizes": [1, 32, 36, 48], + "sizes": [1, 11, 16, 16], "dtype": "float32", "storage": "texture3d", "layout": "channels", @@ -2686,7 +2696,7 @@ ] }, { - "name": "conv2d dw_performance [[1, 384, 32, 32], [3, 3], [1, 1], [1, 1], [1, 1], False] float16", + "name": "conv2d dw_correctness [[1, 3, 16, 16], [3, 3], [1, 1], [1, 1], [1, 1], False] float32 auto", "operator": "aten.convolution.default", "target_ms": 100, "warmup_runs": 1, @@ -2694,20 +2704,17 @@ "max_dispatches": 200, "validation_method": { "function": "is_close", - "atol": 0.1, - "rtol": 0.01 - }, - "route": "v_ROUTE", - "sweep_variables": { - "v_ROUTE": ["b1x1"] + "atol": 0.001, + "rtol": 0.001 }, + "route": "auto", "args_sweep": [ [ { "tensor": { "name": "input", - "sizes": [1, 384, 32, 32], - "dtype": "float16", + "sizes": [1, 3, 16, 16], + "dtype": "float32", "storage": "texture3d", "layout": "channels", "data": { @@ -2718,8 +2725,8 @@ { "tensor": { "name": "weight", - "sizes": [384, 1, 3, 3], - "dtype": "float16", + "sizes": [3, 1, 3, 3], + "dtype": "float32", "storage": "constant", "layout": "channels", "data": { @@ -2744,13 +2751,13 @@ "int_list": [0, 0] }, { - "int": 384 + "int": 3 }, { "tensor": { "name": "output", - "sizes": [1, 384, 32, 32], - "dtype": "float16", + "sizes": [1, 3, 16, 16], + "dtype": "float32", "storage": "texture3d", "layout": "channels", "data": { @@ -2762,28 +2769,25 @@ ] }, { - "name": "conv2d performance [[1, 32, 72, 96], 32, [3, 3], [2, 2], [1, 1], [1, 1], False] float16", + "name": "conv2d dw_correctness [[1, 8, 16, 16], [5, 5], [1, 1], [2, 2], [1, 1], False] float32 auto", "operator": "aten.convolution.default", "target_ms": 100, - "warmup_runs": 5, - "benchmark_runs": 20, + "warmup_runs": 1, + "benchmark_runs": 1, "max_dispatches": 200, "validation_method": { "function": "is_close", - "atol": 0.5, - "rtol": 0.01 - }, - "route": "v_ROUTE", - "sweep_variables": { - "v_ROUTE": ["direct"] + "atol": 0.001, + "rtol": 0.001 }, + "route": "auto", "args_sweep": [ [ { "tensor": { "name": "input", - "sizes": [1, 32, 72, 96], - "dtype": "float16", + "sizes": [1, 8, 16, 16], + "dtype": "float32", "storage": "texture3d", "layout": "channels", "data": { @@ -2794,8 +2798,8 @@ { "tensor": { "name": "weight", - "sizes": [32, 32, 3, 3], - "dtype": "float16", + "sizes": [8, 1, 5, 5], + "dtype": "float32", "storage": "constant", "layout": "channels", "data": { @@ -2805,10 +2809,10 @@ }, null, { - "int_list": [2, 2] + "int_list": [1, 1] }, { - "int_list": [1, 1] + "int_list": [2, 2] }, { "int_list": [1, 1] @@ -2820,13 +2824,13 @@ "int_list": [0, 0] }, { - "int": 1 + "int": 8 }, { "tensor": { "name": "output", - "sizes": [1, 32, 36, 48], - "dtype": "float16", + "sizes": [1, 8, 16, 16], + "dtype": "float32", "storage": "texture3d", "layout": "channels", "data": { @@ -2838,7 +2842,7 @@ ] }, { - "name": "conv2d dw_performance [[1, 384, 32, 32], [3, 3], [1, 1], [1, 1], [1, 1], False] float16", + "name": "conv2d dw_correctness [[1, 8, 16, 16],", "operator": "aten.convolution.default", "target_ms": 100, "warmup_runs": 1, @@ -2846,20 +2850,23 @@ "max_dispatches": 200, "validation_method": { "function": "is_close", - "atol": 0.1, - "rtol": 0.01 + "atol": 0.001, + "rtol": 0.001 }, - "route": "v_ROUTE", + "route": "auto", "sweep_variables": { - "v_ROUTE": ["b4x2"] + "v_WEIGHT_SIZES": [ + [8, 1, 3, 3], + [8, 1, 4, 4] + ] }, "args_sweep": [ [ { "tensor": { "name": "input", - "sizes": [1, 384, 32, 32], - "dtype": "float16", + "sizes": [1, 8, 16, 16], + "dtype": "float32", "storage": "texture3d", "layout": "channels", "data": { @@ -2870,8 +2877,8 @@ { "tensor": { "name": "weight", - "sizes": [384, 1, 3, 3], - "dtype": "float16", + "sizes": "v_WEIGHT_SIZES", + "dtype": "float32", "storage": "constant", "layout": "channels", "data": { @@ -2881,7 +2888,7 @@ }, null, { - "int_list": [1, 1] + "int_list": [2, 2] }, { "int_list": [1, 1] @@ -2896,13 +2903,13 @@ "int_list": [0, 0] }, { - "int": 384 + "int": 8 }, { "tensor": { "name": "output", - "sizes": [1, 384, 32, 32], - "dtype": "float16", + "sizes": [1, 8, 8, 8], + "dtype": "float32", "storage": "texture3d", "layout": "channels", "data": { @@ -2914,7 +2921,7 @@ ] }, { - "name": "conv2d dw_performance [[1, 192, 64, 64], [3, 3], [2, 2], [1, 1], [1, 1], False] float16", + "name": "conv2d dw_correctness [[1, 8, 32, 32], [6, 6], [3, 3], [2, 2], [3, 3], False] float32 auto", "operator": "aten.convolution.default", "target_ms": 100, "warmup_runs": 1, @@ -2922,20 +2929,17 @@ "max_dispatches": 200, "validation_method": { "function": "is_close", - "atol": 0.1, - "rtol": 0.01 - }, - "route": "v_ROUTE", - "sweep_variables": { - "v_ROUTE": ["auto"] + "atol": 0.001, + "rtol": 0.001 }, + "route": "auto", "args_sweep": [ [ { "tensor": { "name": "input", - "sizes": [1, 192, 64, 64], - "dtype": "float16", + "sizes": [1, 8, 32, 32], + "dtype": "float32", "storage": "texture3d", "layout": "channels", "data": { @@ -2946,8 +2950,8 @@ { "tensor": { "name": "weight", - "sizes": [192, 1, 3, 3], - "dtype": "float16", + "sizes": [8, 1, 6, 6], + "dtype": "float32", "storage": "constant", "layout": "channels", "data": { @@ -2957,13 +2961,13 @@ }, null, { - "int_list": [2, 2] + "int_list": [3, 3] }, { - "int_list": [1, 1] + "int_list": [2, 2] }, { - "int_list": [1, 1] + "int_list": [3, 3] }, { "bool": false @@ -2972,13 +2976,13 @@ "int_list": [0, 0] }, { - "int": 192 + "int": 8 }, { "tensor": { "name": "output", - "sizes": [1, 192, 32, 32], - "dtype": "float16", + "sizes": [1, 8, 7, 7], + "dtype": "float32", "storage": "texture3d", "layout": "channels", "data": { @@ -2990,25 +2994,25 @@ ] }, { - "name": "conv2d pw_performance [1, 384, 768, 32, 32, False] float16 auto", + "name": "conv2d dw_correctness [[1, 8, 16, 16], [3, 3], [1, 1], [1, 1], [1, 1], True] float32 auto", "operator": "aten.convolution.default", - "route": "auto", "target_ms": 100, "warmup_runs": 1, "benchmark_runs": 1, "max_dispatches": 200, "validation_method": { "function": "is_close", - "atol": 0.1, - "rtol": 0.01 + "atol": 0.001, + "rtol": 0.001 }, + "route": "auto", "args_sweep": [ [ { "tensor": { "name": "input", - "sizes": [1, 384, 32, 32], - "dtype": "float16", + "sizes": [1, 8, 16, 16], + "dtype": "float32", "storage": "texture3d", "layout": "channels", "data": { @@ -3019,8 +3023,20 @@ { "tensor": { "name": "weight", - "sizes": [768, 384, 1, 1], - "dtype": "float16", + "sizes": [8, 1, 3, 3], + "dtype": "float32", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [8], + "dtype": "float32", "storage": "constant", "layout": "channels", "data": { @@ -3028,12 +3044,11 @@ } } }, - null, { "int_list": [1, 1] }, { - "int_list": [0, 0] + "int_list": [1, 1] }, { "int_list": [1, 1] @@ -3045,13 +3060,13 @@ "int_list": [0, 0] }, { - "int": 1 + "int": 8 }, { "tensor": { "name": "output", - "sizes": [1, 768, 32, 32], - "dtype": "float16", + "sizes": [1, 8, 16, 16], + "dtype": "float32", "storage": "texture3d", "layout": "channels", "data": { @@ -3063,7 +3078,7 @@ ] }, { - "name": "conv2d dw_performance [[1, 384, 32, 32], [3, 3], [1, 1], [1, 1], [1, 1], False] float32", + "name": "conv2d dw_correctness [[1, 8, 32, 32], [7, 7], [1, 1], [6, 6], [2, 2], True] float32 auto", "operator": "aten.convolution.default", "target_ms": 100, "warmup_runs": 1, @@ -3074,16 +3089,13 @@ "atol": 0.001, "rtol": 0.001 }, - "route": "v_ROUTE", - "sweep_variables": { - "v_ROUTE": ["b4x2"] - }, + "route": "auto", "args_sweep": [ [ { "tensor": { "name": "input", - "sizes": [1, 384, 32, 32], + "sizes": [1, 8, 32, 32], "dtype": "float32", "storage": "texture3d", "layout": "channels", @@ -3095,7 +3107,7 @@ { "tensor": { "name": "weight", - "sizes": [384, 1, 3, 3], + "sizes": [8, 1, 7, 7], "dtype": "float32", "storage": "constant", "layout": "channels", @@ -3104,15 +3116,26 @@ } } }, - null, { - "int_list": [1, 1] + "tensor": { + "name": "bias", + "sizes": [8], + "dtype": "float32", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } }, { "int_list": [1, 1] }, { - "int_list": [1, 1] + "int_list": [6, 6] + }, + { + "int_list": [2, 2] }, { "bool": false @@ -3121,12 +3144,12 @@ "int_list": [0, 0] }, { - "int": 384 + "int": 8 }, { "tensor": { "name": "output", - "sizes": [1, 384, 32, 32], + "sizes": [1, 8, 32, 32], "dtype": "float32", "storage": "texture3d", "layout": "channels", @@ -3139,7 +3162,7 @@ ] }, { - "name": "conv2d dw_performance [[1, 384, 32, 32], [3, 3], [1, 1], [1, 1], [1, 1], False] float32", + "name": "conv2d dw_correctness [[1, 8, 31, 37], [3, 5], [2, 1], [1, 2], [1, 1], True] float32 auto", "operator": "aten.convolution.default", "target_ms": 100, "warmup_runs": 1, @@ -3150,16 +3173,176 @@ "atol": 0.001, "rtol": 0.001 }, + "route": "auto", + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 8, 31, 37], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [8, 1, 3, 5], + "dtype": "float32", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [8], + "dtype": "float32", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "int_list": [2, 1] + }, + { + "int_list": [1, 2] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 8 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 8, 16, 37], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d performance [[1, 64, 36, 48], 32, [3, 3], [1, 1], [1, 1], [1, 1], False] float16", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 5, + "benchmark_runs": 20, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0, + "rtol": 0.01 + }, "route": "v_ROUTE", "sweep_variables": { - "v_ROUTE": ["b1x1"] + "v_ROUTE": ["auto"] }, "args_sweep": [ [ { "tensor": { "name": "input", - "sizes": [1, 384, 32, 32], + "sizes": [1, 64, 36, 48], + "dtype": "float16", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [32, 64, 3, 3], + "dtype": "float16", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 32, 36, 48], + "dtype": "float16", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d performance [[1, 64, 36, 48], 32, [3, 3], [1, 1], [1, 1], [1, 1], False] float32", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 5, + "benchmark_runs": 20, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["auto"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 64, 36, 48], "dtype": "float32", "storage": "texture3d", "layout": "channels", @@ -3171,7 +3354,7 @@ { "tensor": { "name": "weight", - "sizes": [384, 1, 3, 3], + "sizes": [32, 64, 3, 3], "dtype": "float32", "storage": "constant", "layout": "channels", @@ -3197,12 +3380,12 @@ "int_list": [0, 0] }, { - "int": 384 + "int": 1 }, { "tensor": { "name": "output", - "sizes": [1, 384, 32, 32], + "sizes": [1, 32, 36, 48], "dtype": "float32", "storage": "texture3d", "layout": "channels", @@ -3215,7 +3398,7 @@ ] }, { - "name": "conv2d dw_performance [[1, 96, 128, 128], [5, 5], [1, 1], [2, 2], [1, 1], False] float16", + "name": "conv2d dw_performance [[1, 384, 32, 32], [3, 3], [1, 1], [1, 1], [1, 1], False] float16", "operator": "aten.convolution.default", "target_ms": 100, "warmup_runs": 1, @@ -3228,14 +3411,14 @@ }, "route": "v_ROUTE", "sweep_variables": { - "v_ROUTE": ["auto"] + "v_ROUTE": ["b1x1"] }, "args_sweep": [ [ { "tensor": { "name": "input", - "sizes": [1, 96, 128, 128], + "sizes": [1, 384, 32, 32], "dtype": "float16", "storage": "texture3d", "layout": "channels", @@ -3247,7 +3430,7 @@ { "tensor": { "name": "weight", - "sizes": [96, 1, 5, 5], + "sizes": [384, 1, 3, 3], "dtype": "float16", "storage": "constant", "layout": "channels", @@ -3261,7 +3444,7 @@ "int_list": [1, 1] }, { - "int_list": [2, 2] + "int_list": [1, 1] }, { "int_list": [1, 1] @@ -3273,12 +3456,12 @@ "int_list": [0, 0] }, { - "int": 96 + "int": 384 }, { "tensor": { "name": "output", - "sizes": [1, 96, 128, 128], + "sizes": [1, 384, 32, 32], "dtype": "float16", "storage": "texture3d", "layout": "channels", @@ -3291,27 +3474,27 @@ ] }, { - "name": "conv2d dw_performance [[1, 96, 128, 128], [5, 5], [1, 1], [2, 2], [1, 1], False] float16", + "name": "conv2d performance [[1, 32, 72, 96], 32, [3, 3], [2, 2], [1, 1], [1, 1], False] float16", "operator": "aten.convolution.default", "target_ms": 100, - "warmup_runs": 1, - "benchmark_runs": 1, + "warmup_runs": 5, + "benchmark_runs": 20, "max_dispatches": 200, "validation_method": { "function": "is_close", - "atol": 0.1, + "atol": 0.5, "rtol": 0.01 }, "route": "v_ROUTE", "sweep_variables": { - "v_ROUTE": ["b4x2"] + "v_ROUTE": ["direct"] }, "args_sweep": [ [ { "tensor": { "name": "input", - "sizes": [1, 96, 128, 128], + "sizes": [1, 32, 72, 96], "dtype": "float16", "storage": "texture3d", "layout": "channels", @@ -3323,7 +3506,7 @@ { "tensor": { "name": "weight", - "sizes": [96, 1, 5, 5], + "sizes": [32, 32, 3, 3], "dtype": "float16", "storage": "constant", "layout": "channels", @@ -3334,10 +3517,10 @@ }, null, { - "int_list": [1, 1] + "int_list": [2, 2] }, { - "int_list": [2, 2] + "int_list": [1, 1] }, { "int_list": [1, 1] @@ -3349,12 +3532,12 @@ "int_list": [0, 0] }, { - "int": 96 + "int": 1 }, { "tensor": { "name": "output", - "sizes": [1, 96, 128, 128], + "sizes": [1, 32, 36, 48], "dtype": "float16", "storage": "texture3d", "layout": "channels", @@ -3367,7 +3550,7 @@ ] }, { - "name": "conv2d dw_performance [[1, 96, 128, 128], [5, 5], [1, 1], [2, 2], [1, 1], False] float32", + "name": "conv2d dw_performance [[1, 384, 32, 32], [3, 3], [1, 1], [1, 1], [1, 1], False] float16", "operator": "aten.convolution.default", "target_ms": 100, "warmup_runs": 1, @@ -3375,8 +3558,8 @@ "max_dispatches": 200, "validation_method": { "function": "is_close", - "atol": 0.001, - "rtol": 0.001 + "atol": 0.1, + "rtol": 0.01 }, "route": "v_ROUTE", "sweep_variables": { @@ -3387,8 +3570,8 @@ { "tensor": { "name": "input", - "sizes": [1, 96, 128, 128], - "dtype": "float32", + "sizes": [1, 384, 32, 32], + "dtype": "float16", "storage": "texture3d", "layout": "channels", "data": { @@ -3399,8 +3582,8 @@ { "tensor": { "name": "weight", - "sizes": [96, 1, 5, 5], - "dtype": "float32", + "sizes": [384, 1, 3, 3], + "dtype": "float16", "storage": "constant", "layout": "channels", "data": { @@ -3413,7 +3596,7 @@ "int_list": [1, 1] }, { - "int_list": [2, 2] + "int_list": [1, 1] }, { "int_list": [1, 1] @@ -3425,13 +3608,4830 @@ "int_list": [0, 0] }, { - "int": 96 + "int": 384 }, { "tensor": { "name": "output", - "sizes": [1, 96, 128, 128], - "dtype": "float32", + "sizes": [1, 384, 32, 32], + "dtype": "float16", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d dw_performance [[1, 192, 64, 64], [3, 3], [2, 2], [1, 1], [1, 1], False] float16", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.1, + "rtol": 0.01 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["auto"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 192, 64, 64], + "dtype": "float16", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [192, 1, 3, 3], + "dtype": "float16", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 192 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 192, 32, 32], + "dtype": "float16", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d pw_performance [1, 384, 768, 32, 32, False] float16 auto", + "operator": "aten.convolution.default", + "route": "auto", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.1, + "rtol": 0.01 + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 384, 32, 32], + "dtype": "float16", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [768, 384, 1, 1], + "dtype": "float16", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 768, 32, 32], + "dtype": "float16", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d dw_performance [[1, 384, 32, 32], [3, 3], [1, 1], [1, 1], [1, 1], False] float32", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["b4x2"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 384, 32, 32], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [384, 1, 3, 3], + "dtype": "float32", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 384 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 384, 32, 32], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d dw_performance [[1, 384, 32, 32], [3, 3], [1, 1], [1, 1], [1, 1], False] float32", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["b1x1"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 384, 32, 32], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [384, 1, 3, 3], + "dtype": "float32", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 384 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 384, 32, 32], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d dw_performance [[1, 96, 128, 128], [5, 5], [1, 1], [2, 2], [1, 1], False] float16", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.1, + "rtol": 0.01 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["auto"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 96, 128, 128], + "dtype": "float16", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [96, 1, 5, 5], + "dtype": "float16", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 96 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 96, 128, 128], + "dtype": "float16", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d dw_performance [[1, 96, 128, 128], [5, 5], [1, 1], [2, 2], [1, 1], False] float16", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.1, + "rtol": 0.01 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["b4x2"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 96, 128, 128], + "dtype": "float16", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [96, 1, 5, 5], + "dtype": "float16", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 96 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 96, 128, 128], + "dtype": "float16", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d dw_performance [[1, 96, 128, 128], [5, 5], [1, 1], [2, 2], [1, 1], False] float32", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 1, + "benchmark_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "route": "v_ROUTE", + "sweep_variables": { + "v_ROUTE": ["b4x2"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 96, 128, 128], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [96, 1, 5, 5], + "dtype": "float32", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_float": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 96 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 96, 128, 128], + "dtype": "float32", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d direct_correctness", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 5, + "benchmark_runs": 20, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "route_sweep": [ + "direct", + { + "name": "direct", + "options": { + "masked": true + } + } + ], + "sweep_variables": { + "v_DTYPE": ["float32", "float16"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 16, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [16, 16, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 16, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 3, 15, 19], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [8, 3, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 8, 8, 10], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 11, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [13, 11, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 13, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 17, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [17, 17, 5, 5], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 17, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 16, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [32, 16, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 32, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 16, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [32, 16, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [32], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 32, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 16, 11, 17], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [24, 16, 3, 5], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [2, 1] + }, + { + "int_list": [0, 3] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 24, 5, 19], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 16, 11, 17], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [16, 16, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [2, 3] + }, + { + "int_list": [2, 3] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 16, 11, 17], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 8, 11, 17], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [8, 8, 7, 7], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [3, 3] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 8, 11, 17], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 16, 1, 29], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [32, 16, 1, 5], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 2] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 32, 1, 29], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 8, 1, 1], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [8, 8, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [8], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [3, 4] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 8, 5, 7], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 64, 8, 12], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 64, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 8, 12], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 64, 8, 12], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 64, 5, 5], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 8, 12], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 128, 8, 12], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [128, 128, 5, 5], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 128, 8, 12], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 16, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [16, 8, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 2 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 16, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 16, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [24, 8, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 2 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 24, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 6, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [16, 3, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 2 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 16, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 16, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [10, 8, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 2 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 10, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 6, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [10, 3, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 2 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 10, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 4, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [8, 1, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 4 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 8, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 3, 3, 3], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [1, 3, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 1, 1, 1], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 6, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [8, 3, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 2 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 8, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 6, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [24, 3, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 2 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 24, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d direct_correctness clamp", + "operator": "et_vk.conv_with_clamp.default", + "target_ms": 100, + "warmup_runs": 5, + "benchmark_runs": 20, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "route_sweep": [ + "direct", + { + "name": "direct", + "options": { + "masked": true + } + } + ], + "sweep_variables": { + "v_DTYPE": ["float32", "float16"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 16, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [32, 16, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "float": -0.25 + }, + { + "float": 0.5 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 32, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 16, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [32, 16, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [32], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "float": -0.25 + }, + { + "float": 0.5 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 32, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d dsr_convs_correctness", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 5, + "benchmark_runs": 20, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "route_sweep": ["auto"], + "sweep_variables": { + "v_DTYPE": ["float32", "float16"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 17, 8, 12], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 17, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 8, 12], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 32, 8, 12], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [32, 32, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 32, 8, 12], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 64, 8, 12], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 64, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 8, 12], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 128, 8, 12], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [128, 128, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 128, 8, 12], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 64, 8, 12], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 64, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 8, 12], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 32, 8, 12], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [32, 32, 5, 5], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 32, 8, 12], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 64, 8, 12], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 64, 5, 5], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 8, 12], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 128, 8, 12], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [128, 128, 5, 5], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 128, 8, 12], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d dsr_convs_correctness clamp", + "operator": "et_vk.conv_with_clamp.default", + "target_ms": 100, + "warmup_runs": 5, + "benchmark_runs": 20, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "route_sweep": ["auto"], + "sweep_variables": { + "v_DTYPE": ["float32", "float16"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 17, 8, 12], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 17, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [64], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "float": 0.0 + }, + { + "float": 3.4028234663852886e38 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 8, 12], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 32, 8, 12], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [32, 32, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [32], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "float": 0.0 + }, + { + "float": 3.4028234663852886e38 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 32, 8, 12], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 64, 8, 12], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 64, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [64], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "float": 0.0 + }, + { + "float": 3.4028234663852886e38 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 8, 12], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 128, 8, 12], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [128, 128, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [128], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "float": 0.0 + }, + { + "float": 3.4028234663852886e38 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 128, 8, 12], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 64, 8, 12], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 64, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [64], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "float": 0.0 + }, + { + "float": 3.4028234663852886e38 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 8, 12], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 32, 8, 12], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [32, 32, 5, 5], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [32], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "float": 0.0 + }, + { + "float": 3.4028234663852886e38 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 32, 8, 12], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 64, 8, 12], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 64, 5, 5], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [64], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "float": 0.0 + }, + { + "float": 3.4028234663852886e38 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 8, 12], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 128, 8, 12], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [128, 128, 5, 5], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [128], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "float": 0.0 + }, + { + "float": 3.4028234663852886e38 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 128, 8, 12], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d dsr_bias_clamp_correctness", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 5, + "benchmark_runs": 20, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "route_sweep": ["direct"], + "sweep_variables": { + "v_DTYPE": ["float16"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 17, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 17, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [64], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 32, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [32, 32, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [32], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 32, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 64, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 64, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [64], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 128, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [128, 128, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [128], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 128, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 64, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 64, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [64], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 32, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [32, 32, 5, 5], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [32], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 32, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 64, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 64, 5, 5], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [64], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 128, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [128, 128, 5, 5], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [128], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 128, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d dsr_bias_clamp_correctness clamp", + "operator": "et_vk.conv_with_clamp.default", + "target_ms": 100, + "warmup_runs": 5, + "benchmark_runs": 20, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "route_sweep": ["direct"], + "sweep_variables": { + "v_DTYPE": ["float16"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 17, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 17, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "float": 0.0 + }, + { + "float": 6.0 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 17, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 17, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [64], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "float": 0.0 + }, + { + "float": 3.4028234663852886e38 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 32, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [32, 32, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "float": 0.0 + }, + { + "float": 6.0 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 32, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 32, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [32, 32, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [32], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "float": 0.0 + }, + { + "float": 3.4028234663852886e38 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 32, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 64, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 64, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "float": 0.0 + }, + { + "float": 6.0 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 64, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 64, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [64], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "float": 0.0 + }, + { + "float": 3.4028234663852886e38 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 128, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [128, 128, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "float": 0.0 + }, + { + "float": 6.0 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 128, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 128, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [128, 128, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [128], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "float": 0.0 + }, + { + "float": 3.4028234663852886e38 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 128, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 64, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 64, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "float": 0.0 + }, + { + "float": 6.0 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 64, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 64, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [64], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "float": 0.0 + }, + { + "float": 3.4028234663852886e38 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 32, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [32, 32, 5, 5], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "float": 0.0 + }, + { + "float": 6.0 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 32, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 32, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [32, 32, 5, 5], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [32], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "float": 0.0 + }, + { + "float": 3.4028234663852886e38 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 32, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 64, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 64, 5, 5], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "float": 0.0 + }, + { + "float": 6.0 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 64, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 64, 5, 5], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [64], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "float": 0.0 + }, + { + "float": 3.4028234663852886e38 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 128, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [128, 128, 5, 5], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "float": 0.0 + }, + { + "float": 6.0 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 128, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 128, 9, 13], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [128, 128, 5, 5], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [128], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "float": 0.0 + }, + { + "float": 3.4028234663852886e38 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 128, 9, 13], + "dtype": "v_DTYPE", "storage": "texture3d", "layout": "channels", "data": { diff --git a/backends/vulkan/test/ops/benchmark_v2/operators/convolution/conv2d/cases/direct_performance.json b/backends/vulkan/test/ops/benchmark_v2/operators/convolution/conv2d/cases/direct_performance.json new file mode 100644 index 00000000000..fe290fd1bf5 --- /dev/null +++ b/backends/vulkan/test/ops/benchmark_v2/operators/convolution/conv2d/cases/direct_performance.json @@ -0,0 +1,912 @@ +[ + { + "name": "conv2d direct_performance", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 5, + "benchmark_runs": 20, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "route_sweep": ["direct"], + "sweep_variables": { + "v_DTYPE": ["float32", "float16"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 16, 48, 64], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [16, 16, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 16, 48, 64], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 3, 65, 97], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [8, 3, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 8, 33, 49], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 11, 48, 64], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [13, 11, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 13, 48, 64], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 17, 49, 65], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [17, 17, 5, 5], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 17, 49, 65], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 16, 31, 47], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [24, 16, 3, 5], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [2, 1] + }, + { + "int_list": [0, 3] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 24, 15, 49], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 16, 48, 64], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [16, 16, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [2, 3] + }, + { + "int_list": [2, 3] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 16, 48, 64], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 8, 32, 48], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [8, 8, 7, 7], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [3, 3] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 8, 32, 48], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 16, 1, 1537], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [32, 16, 1, 5], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 2] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 32, 1, 1537], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 64, 32, 48], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 64, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 32, 48], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 64, 32, 48], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 64, 5, 5], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 32, 48], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 128, 16, 24], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [128, 128, 5, 5], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 128, 16, 24], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 16, 48, 64], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [16, 8, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 2 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 16, 48, 64], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 16, 48, 64], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [24, 8, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 2 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 24, 48, 64], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 3, 3, 3], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [1, 3, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 1, 1, 1], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d direct_performance clamp", + "operator": "et_vk.conv_with_clamp.default", + "target_ms": 100, + "warmup_runs": 5, + "benchmark_runs": 20, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "route_sweep": ["direct"], + "sweep_variables": { + "v_DTYPE": ["float32", "float16"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 16, 48, 64], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [32, 16, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [32], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "float": -0.25 + }, + { + "float": 0.5 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 32, 48, 64], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + } +] diff --git a/backends/vulkan/test/ops/benchmark_v2/operators/convolution/conv2d/cases/dsr_performance.json b/backends/vulkan/test/ops/benchmark_v2/operators/convolution/conv2d/cases/dsr_performance.json new file mode 100644 index 00000000000..5ab85064281 --- /dev/null +++ b/backends/vulkan/test/ops/benchmark_v2/operators/convolution/conv2d/cases/dsr_performance.json @@ -0,0 +1,1757 @@ +[ + { + "name": "conv2d dsr_convs direct vs auto", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 5, + "benchmark_runs": 20, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "route_sweep": ["direct", "auto"], + "sweep_variables": { + "v_DTYPE": ["float16"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 17, 756, 1008], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 17, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 756, 1008], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 32, 756, 1008], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [32, 32, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 32, 756, 1008], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 64, 378, 504], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 64, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 378, 504], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 64, 756, 1008], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 64, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 756, 1008], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 32, 756, 1008], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [32, 32, 5, 5], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 32, 756, 1008], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 64, 378, 504], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 64, 5, 5], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 378, 504], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d dsr_convs direct vs auto clamp", + "operator": "et_vk.conv_with_clamp.default", + "target_ms": 100, + "warmup_runs": 5, + "benchmark_runs": 20, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "route_sweep": ["direct", "auto"], + "sweep_variables": { + "v_DTYPE": ["float16"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 17, 756, 1008], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 17, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [64], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "float": 0.0 + }, + { + "float": 3.4028234663852886e38 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 756, 1008], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 32, 756, 1008], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [32, 32, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [32], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "float": 0.0 + }, + { + "float": 3.4028234663852886e38 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 32, 756, 1008], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 64, 378, 504], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 64, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [64], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "float": 0.0 + }, + { + "float": 3.4028234663852886e38 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 378, 504], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 64, 756, 1008], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 64, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [64], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "float": 0.0 + }, + { + "float": 3.4028234663852886e38 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 756, 1008], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 32, 756, 1008], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [32, 32, 5, 5], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [32], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "float": 0.0 + }, + { + "float": 3.4028234663852886e38 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 32, 756, 1008], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 64, 378, 504], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 64, 5, 5], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [64], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "float": 0.0 + }, + { + "float": 3.4028234663852886e38 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 378, 504], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d dsr_convs im2col vs auto", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 5, + "benchmark_runs": 20, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "route_sweep": ["im2col", "auto"], + "sweep_variables": { + "v_DTYPE": ["float16"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 128, 189, 252], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [128, 128, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 128, 189, 252], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 128, 189, 252], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [128, 128, 5, 5], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + null, + { + "int_list": [1, 1] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 128, 189, 252], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d dsr_convs im2col vs auto clamp", + "operator": "et_vk.conv_with_clamp.default", + "target_ms": 100, + "warmup_runs": 5, + "benchmark_runs": 20, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "route_sweep": ["im2col", "auto"], + "sweep_variables": { + "v_DTYPE": ["float16"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 128, 189, 252], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [128, 128, 3, 3], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [128], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "float": 0.0 + }, + { + "float": 3.4028234663852886e38 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 128, 189, 252], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 128, 189, 252], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [128, 128, 5, 5], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [128], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [2, 2] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "float": 0.0 + }, + { + "float": 3.4028234663852886e38 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 128, 189, 252], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "conv2d dsr_pw", + "operator": "aten.convolution.default", + "target_ms": 100, + "warmup_runs": 5, + "benchmark_runs": 20, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0.001, + "rtol": 0.001 + }, + "sweep_variables": { + "v_DTYPE": ["float16"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 64, 756, 1008], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 64, 1, 1], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [64], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 756, 1008], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 32, 756, 1008], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [128, 32, 1, 1], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [128], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 128, 756, 1008], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 128, 756, 1008], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [32, 128, 1, 1], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [32], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 32, 756, 1008], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 128, 378, 504], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [128, 128, 1, 1], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [128], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 128, 378, 504], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 64, 378, 504], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [256, 64, 1, 1], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [256], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 256, 378, 504], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 256, 378, 504], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [64, 256, 1, 1], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [64], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 378, 504], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 256, 189, 252], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [256, 256, 1, 1], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [256], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 256, 189, 252], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 128, 189, 252], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [512, 128, 1, 1], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [512], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 512, 189, 252], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ], + [ + { + "tensor": { + "name": "input", + "sizes": [1, 512, 189, 252], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "weight", + "sizes": [128, 512, 1, 1], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "tensor": { + "name": "bias", + "sizes": [128], + "dtype": "v_DTYPE", + "storage": "constant", + "layout": "channels", + "data": { + "rand_uniform_int": [-1, 1] + } + } + }, + { + "int_list": [1, 1] + }, + { + "int_list": [0, 0] + }, + { + "int_list": [1, 1] + }, + { + "bool": false + }, + { + "int_list": [0, 0] + }, + { + "int": 1 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 128, 189, 252], + "dtype": "v_DTYPE", + "storage": "texture3d", + "layout": "channels", + "data": { + "fill": 0 + } + } + } + ] + ], + "route": "auto" + } +] diff --git a/backends/vulkan/test/ops/benchmark_v2/operators/pixel_shuffle/q8ta/cases/correctness.json b/backends/vulkan/test/ops/benchmark_v2/operators/pixel_shuffle/q8ta/cases/correctness.json index 124af8ff463..60e6724a309 100644 --- a/backends/vulkan/test/ops/benchmark_v2/operators/pixel_shuffle/q8ta/cases/correctness.json +++ b/backends/vulkan/test/ops/benchmark_v2/operators/pixel_shuffle/q8ta/cases/correctness.json @@ -8,12 +8,12 @@ "max_dispatches": 200, "validation_method": { "function": "is_close", - "atol": 1.014060718249784, + "atol": 0, "rtol": 0 }, "sweep_variables": { - "v_INPUT_LAYOUT": ["4w4c", "4c1w"], - "v_OUTPUT_LAYOUT": ["4w4c", "4c1w"] + "v_INPUT_LAYOUT": ["4w4c", "4c", "4c1w"], + "v_OUTPUT_LAYOUT": ["4w4c", "4c", "4c1w"] }, "args_sweep": [ [ @@ -30,13 +30,13 @@ } }, { - "float": 0.00711200013756752 + "float": 1.0 }, { "int": 0 }, { - "float": 140.607421875 + "float": 1.0 }, { "int": 0 @@ -68,12 +68,12 @@ "max_dispatches": 200, "validation_method": { "function": "is_close", - "atol": 1.014060718249784, + "atol": 0, "rtol": 0 }, "sweep_variables": { - "v_INPUT_LAYOUT": ["4w4c", "4c1w"], - "v_OUTPUT_LAYOUT": ["4w4c", "4c1w"] + "v_INPUT_LAYOUT": ["4w4c", "4c", "4c1w"], + "v_OUTPUT_LAYOUT": ["4w4c", "4c", "4c1w"] }, "args_sweep": [ [ @@ -90,13 +90,13 @@ } }, { - "float": 0.00711200013756752 + "float": 1.0 }, { "int": 0 }, { - "float": 140.607421875 + "float": 1.0 }, { "int": 0 @@ -128,12 +128,12 @@ "max_dispatches": 200, "validation_method": { "function": "is_close", - "atol": 1.014060718249784, + "atol": 0, "rtol": 0 }, "sweep_variables": { - "v_INPUT_LAYOUT": ["4w4c", "4c1w"], - "v_OUTPUT_LAYOUT": ["4w4c", "4c1w"] + "v_INPUT_LAYOUT": ["4w4c", "4c", "4c1w"], + "v_OUTPUT_LAYOUT": ["4w4c", "4c", "4c1w"] }, "args_sweep": [ [ @@ -150,13 +150,13 @@ } }, { - "float": 0.00711200013756752 + "float": 1.0 }, { "int": 0 }, { - "float": 140.607421875 + "float": 1.0 }, { "int": 0 @@ -188,12 +188,12 @@ "max_dispatches": 200, "validation_method": { "function": "is_close", - "atol": 1.014060718249784, + "atol": 0, "rtol": 0 }, "sweep_variables": { - "v_INPUT_LAYOUT": ["4w4c", "4c1w"], - "v_OUTPUT_LAYOUT": ["4w4c", "4c1w"] + "v_INPUT_LAYOUT": ["4w4c", "4c", "4c1w"], + "v_OUTPUT_LAYOUT": ["4w4c", "4c", "4c1w"] }, "args_sweep": [ [ @@ -210,13 +210,13 @@ } }, { - "float": 0.00711200013756752 + "float": 1.0 }, { "int": 0 }, { - "float": 140.607421875 + "float": 1.0 }, { "int": 0 @@ -246,14 +246,134 @@ "benchmark_runs": 1, "warmup_runs": 1, "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0, + "rtol": 0 + }, + "sweep_variables": { + "v_INPUT_LAYOUT": ["4w4c", "4c", "4c1w"], + "v_OUTPUT_LAYOUT": ["4w4c", "4c", "4c1w"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 4, 5, 7], + "dtype": "int8x4", + "storage": "buffer", + "layout": "v_INPUT_LAYOUT", + "data": { + "rand_uniform_int": [-128, 127] + } + } + }, + { + "float": 1.0 + }, + { + "int": 0 + }, + { + "float": 1.0 + }, + { + "int": 0 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 1, 10, 14], + "dtype": "int8x4", + "storage": "buffer", + "layout": "v_OUTPUT_LAYOUT", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/pixel_shuffle/correctness (6)", + "operator": "et_vk.q8ta_pixel_shuffle.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0, + "rtol": 0 + }, + "sweep_variables": { + "v_INPUT_LAYOUT": ["4w4c", "4c", "4c1w"], + "v_OUTPUT_LAYOUT": ["4w4c", "4c", "4c1w"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [2, 32, 6, 5], + "dtype": "int8x4", + "storage": "buffer", + "layout": "v_INPUT_LAYOUT", + "data": { + "rand_uniform_int": [-128, 127] + } + } + }, + { + "float": 1.0 + }, + { + "int": 0 + }, + { + "float": 1.0 + }, + { + "int": 0 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "output", + "sizes": [2, 8, 12, 10], + "dtype": "int8x4", + "storage": "buffer", + "layout": "v_OUTPUT_LAYOUT", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/pixel_shuffle/correctness (7)", + "operator": "et_vk.q8ta_pixel_shuffle.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, "validation_method": { "function": "is_close", "atol": 1.0076922944664253, "rtol": 0 }, "sweep_variables": { - "v_INPUT_LAYOUT": ["4w4c", "4c1w"], - "v_OUTPUT_LAYOUT": ["4w4c", "4c1w"] + "v_INPUT_LAYOUT": ["4w4c", "4c", "4c1w"], + "v_OUTPUT_LAYOUT": ["4w4c", "4c", "4c1w"] }, "args_sweep": [ [ @@ -300,7 +420,7 @@ ] }, { - "name": "q8ta/pixel_shuffle/correctness (6)", + "name": "q8ta/pixel_shuffle/correctness (8)", "operator": "et_vk.q8ta_pixel_shuffle.default", "target_ms": 100, "benchmark_runs": 1, @@ -312,8 +432,8 @@ "rtol": 0 }, "sweep_variables": { - "v_INPUT_LAYOUT": ["4w4c", "4c1w"], - "v_OUTPUT_LAYOUT": ["4w4c", "4c1w"] + "v_INPUT_LAYOUT": ["4w4c", "4c", "4c1w"], + "v_OUTPUT_LAYOUT": ["4w4c", "4c", "4c1w"] }, "args_sweep": [ [ @@ -360,7 +480,7 @@ ] }, { - "name": "q8ta/pixel_shuffle/correctness (7)", + "name": "q8ta/pixel_shuffle/correctness (9)", "operator": "et_vk.q8ta_pixel_shuffle.default", "target_ms": 100, "benchmark_runs": 1, @@ -372,8 +492,8 @@ "rtol": 0 }, "sweep_variables": { - "v_INPUT_LAYOUT": ["4w4c", "4c1w"], - "v_OUTPUT_LAYOUT": ["4w4c", "4c1w"] + "v_INPUT_LAYOUT": ["4w4c", "4c", "4c1w"], + "v_OUTPUT_LAYOUT": ["4w4c", "4c", "4c1w"] }, "args_sweep": [ [ @@ -420,7 +540,7 @@ ] }, { - "name": "q8ta/pixel_shuffle/correctness (8)", + "name": "q8ta/pixel_shuffle/correctness (10)", "operator": "et_vk.q8ta_pixel_shuffle.default", "target_ms": 100, "benchmark_runs": 1, @@ -432,8 +552,8 @@ "rtol": 0 }, "sweep_variables": { - "v_INPUT_LAYOUT": ["4w4c", "4c1w"], - "v_OUTPUT_LAYOUT": ["4w4c", "4c1w"] + "v_INPUT_LAYOUT": ["4w4c", "4c", "4c1w"], + "v_OUTPUT_LAYOUT": ["4w4c", "4c", "4c1w"] }, "args_sweep": [ [ @@ -478,5 +598,125 @@ } ] ] + }, + { + "name": "q8ta/pixel_shuffle/correctness (11)", + "operator": "et_vk.q8ta_pixel_shuffle.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0076922944664253, + "rtol": 0 + }, + "sweep_variables": { + "v_INPUT_LAYOUT": ["4w4c", "4c", "4c1w"], + "v_OUTPUT_LAYOUT": ["4w4c", "4c", "4c1w"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 4, 5, 7], + "dtype": "int8x4", + "storage": "buffer", + "layout": "v_INPUT_LAYOUT", + "data": { + "rand_uniform_int": [-128, 127] + } + } + }, + { + "float": 0.00711200013756752 + }, + { + "int": 0 + }, + { + "float": 76.9230728149414 + }, + { + "int": 5 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 1, 10, 14], + "dtype": "int8x4", + "storage": "buffer", + "layout": "v_OUTPUT_LAYOUT", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/pixel_shuffle/correctness (12)", + "operator": "et_vk.q8ta_pixel_shuffle.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 1.0076922944664253, + "rtol": 0 + }, + "sweep_variables": { + "v_INPUT_LAYOUT": ["4w4c", "4c", "4c1w"], + "v_OUTPUT_LAYOUT": ["4w4c", "4c", "4c1w"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [2, 32, 6, 5], + "dtype": "int8x4", + "storage": "buffer", + "layout": "v_INPUT_LAYOUT", + "data": { + "rand_uniform_int": [-128, 127] + } + } + }, + { + "float": 0.00711200013756752 + }, + { + "int": 0 + }, + { + "float": 76.9230728149414 + }, + { + "int": 5 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "output", + "sizes": [2, 8, 12, 10], + "dtype": "int8x4", + "storage": "buffer", + "layout": "v_OUTPUT_LAYOUT", + "data": { + "fill": 0 + } + } + } + ] + ] } ] diff --git a/backends/vulkan/test/ops/benchmark_v2/operators/pixel_shuffle/q8ta/cases/performance.json b/backends/vulkan/test/ops/benchmark_v2/operators/pixel_shuffle/q8ta/cases/performance.json index e33e0a9327b..d4efdfc501d 100644 --- a/backends/vulkan/test/ops/benchmark_v2/operators/pixel_shuffle/q8ta/cases/performance.json +++ b/backends/vulkan/test/ops/benchmark_v2/operators/pixel_shuffle/q8ta/cases/performance.json @@ -8,12 +8,12 @@ "max_dispatches": 200, "validation_method": { "function": "is_close", - "atol": 1.014060718249784, + "atol": 0, "rtol": 0 }, "sweep_variables": { - "v_INPUT_LAYOUT": ["4w4c", "4c1w"], - "v_OUTPUT_LAYOUT": ["4w4c", "4c1w"] + "v_INPUT_LAYOUT": ["4w4c", "4c", "4c1w"], + "v_OUTPUT_LAYOUT": ["4w4c", "4c", "4c1w"] }, "args_sweep": [ [ @@ -30,13 +30,13 @@ } }, { - "float": 0.00711200013756752 + "float": 1.0 }, { "int": 0 }, { - "float": 140.607421875 + "float": 1.0 }, { "int": 0 @@ -68,12 +68,12 @@ "max_dispatches": 200, "validation_method": { "function": "is_close", - "atol": 1.014060718249784, + "atol": 0, "rtol": 0 }, "sweep_variables": { - "v_INPUT_LAYOUT": ["4w4c", "4c1w"], - "v_OUTPUT_LAYOUT": ["4w4c", "4c1w"] + "v_INPUT_LAYOUT": ["4w4c", "4c", "4c1w"], + "v_OUTPUT_LAYOUT": ["4w4c", "4c", "4c1w"] }, "args_sweep": [ [ @@ -90,13 +90,13 @@ } }, { - "float": 0.00711200013756752 + "float": 1.0 }, { "int": 0 }, { - "float": 140.607421875 + "float": 1.0 }, { "int": 0 @@ -128,12 +128,12 @@ "max_dispatches": 200, "validation_method": { "function": "is_close", - "atol": 1.014060718249784, + "atol": 0, "rtol": 0 }, "sweep_variables": { - "v_INPUT_LAYOUT": ["4w4c", "4c1w"], - "v_OUTPUT_LAYOUT": ["4w4c", "4c1w"] + "v_INPUT_LAYOUT": ["4w4c", "4c", "4c1w"], + "v_OUTPUT_LAYOUT": ["4w4c", "4c", "4c1w"] }, "args_sweep": [ [ @@ -150,13 +150,13 @@ } }, { - "float": 0.00711200013756752 + "float": 1.0 }, { "int": 0 }, { - "float": 140.607421875 + "float": 1.0 }, { "int": 0 @@ -188,12 +188,12 @@ "max_dispatches": 200, "validation_method": { "function": "is_close", - "atol": 1.014060718249784, + "atol": 0, "rtol": 0 }, "sweep_variables": { - "v_INPUT_LAYOUT": ["4w4c", "4c1w"], - "v_OUTPUT_LAYOUT": ["4w4c", "4c1w"] + "v_INPUT_LAYOUT": ["4w4c", "4c", "4c1w"], + "v_OUTPUT_LAYOUT": ["4w4c", "4c", "4c1w"] }, "args_sweep": [ [ @@ -210,13 +210,13 @@ } }, { - "float": 0.00711200013756752 + "float": 1.0 }, { "int": 0 }, { - "float": 140.607421875 + "float": 1.0 }, { "int": 0 @@ -238,5 +238,305 @@ } ] ] + }, + { + "name": "q8ta/pixel_shuffle/performance (5)", + "operator": "et_vk.q8ta_pixel_shuffle.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0, + "rtol": 0 + }, + "sweep_variables": { + "v_INPUT_LAYOUT": ["4w4c", "4c", "4c1w"], + "v_OUTPUT_LAYOUT": ["4w4c", "4c", "4c1w"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 256, 16, 16], + "dtype": "int8x4", + "storage": "buffer", + "layout": "v_INPUT_LAYOUT", + "data": { + "rand_uniform_int": [-128, 127] + } + } + }, + { + "float": 1.0 + }, + { + "int": 0 + }, + { + "float": 1.0 + }, + { + "int": 0 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 32, 32], + "dtype": "int8x4", + "storage": "buffer", + "layout": "v_OUTPUT_LAYOUT", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/pixel_shuffle/performance (6)", + "operator": "et_vk.q8ta_pixel_shuffle.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0, + "rtol": 0 + }, + "sweep_variables": { + "v_INPUT_LAYOUT": ["4w4c", "4c", "4c1w"], + "v_OUTPUT_LAYOUT": ["4w4c", "4c", "4c1w"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 256, 32, 32], + "dtype": "int8x4", + "storage": "buffer", + "layout": "v_INPUT_LAYOUT", + "data": { + "rand_uniform_int": [-128, 127] + } + } + }, + { + "float": 1.0 + }, + { + "int": 0 + }, + { + "float": 1.0 + }, + { + "int": 0 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 64, 64], + "dtype": "int8x4", + "storage": "buffer", + "layout": "v_OUTPUT_LAYOUT", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/pixel_shuffle/performance (7)", + "operator": "et_vk.q8ta_pixel_shuffle.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0, + "rtol": 0 + }, + "sweep_variables": { + "v_INPUT_LAYOUT": ["4w4c", "4c", "4c1w"], + "v_OUTPUT_LAYOUT": ["4w4c", "4c", "4c1w"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 256, 64, 64], + "dtype": "int8x4", + "storage": "buffer", + "layout": "v_INPUT_LAYOUT", + "data": { + "rand_uniform_int": [-128, 127] + } + } + }, + { + "float": 1.0 + }, + { + "int": 0 + }, + { + "float": 1.0 + }, + { + "int": 0 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 128, 128], + "dtype": "int8x4", + "storage": "buffer", + "layout": "v_OUTPUT_LAYOUT", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/pixel_shuffle/performance (8)", + "operator": "et_vk.q8ta_pixel_shuffle.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0, + "rtol": 0 + }, + "sweep_variables": { + "v_INPUT_LAYOUT": ["4w4c", "4c", "4c1w"], + "v_OUTPUT_LAYOUT": ["4w4c", "4c", "4c1w"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 256, 128, 128], + "dtype": "int8x4", + "storage": "buffer", + "layout": "v_INPUT_LAYOUT", + "data": { + "rand_uniform_int": [-128, 127] + } + } + }, + { + "float": 1.0 + }, + { + "int": 0 + }, + { + "float": 1.0 + }, + { + "int": 0 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 256, 256], + "dtype": "int8x4", + "storage": "buffer", + "layout": "v_OUTPUT_LAYOUT", + "data": { + "fill": 0 + } + } + } + ] + ] + }, + { + "name": "q8ta/pixel_shuffle/performance (9)", + "operator": "et_vk.q8ta_pixel_shuffle.default", + "target_ms": 100, + "benchmark_runs": 1, + "warmup_runs": 1, + "max_dispatches": 200, + "validation_method": { + "function": "is_close", + "atol": 0, + "rtol": 0 + }, + "sweep_variables": { + "v_INPUT_LAYOUT": ["4w4c", "4c", "4c1w"], + "v_OUTPUT_LAYOUT": ["4w4c", "4c", "4c1w"] + }, + "args_sweep": [ + [ + { + "tensor": { + "name": "input", + "sizes": [1, 256, 256, 256], + "dtype": "int8x4", + "storage": "buffer", + "layout": "v_INPUT_LAYOUT", + "data": { + "rand_uniform_int": [-128, 127] + } + } + }, + { + "float": 1.0 + }, + { + "int": 0 + }, + { + "float": 1.0 + }, + { + "int": 0 + }, + { + "int": 2 + }, + { + "tensor": { + "name": "output", + "sizes": [1, 64, 512, 512], + "dtype": "int8x4", + "storage": "buffer", + "layout": "v_OUTPUT_LAYOUT", + "data": { + "fill": 0 + } + } + } + ] + ] } ] From 07e8b1e8dff432d7b3e323106ca82154b6d1c045 Mon Sep 17 00:00:00 2001 From: Stephen Jia Date: Tue, 3 Nov 2026 11:49:44 -0800 Subject: [PATCH 037/128] [ET-VK][benchmark] Remove the legacy operator benchmark Pull Request resolved: https://github.com/pytorch/executorch/pull/24521 ## Ulterior Motive Deprecate the legacy operator benchmark (`etvk_bench`) in favor of `benchmark_v2` (`etvk_ops_bench`). ## Rationale **What**: Delete `test/ops/benchmark/` and the `custom_ops:utils_test` target that depends on it, and update a comment that referenced it. **Why**: CI (Skycastle and GitHub) now runs `benchmark_v2`, and every legacy test set has a `benchmark_v2` equivalent. The legacy benchmark is unused. ## Details - `custom_ops:utils_test` only tested the legacy benchmark's `prototyping_utils`; the other `custom_ops` tests are independent. - `conv2d_gemm_dynamic_test.cpp`: the comment no longer names a legacy source file. Authored with Claude Code (AI). ghstack-source-id: 463942933 @exported-using-ghexport Differential Revision: [D129408346](https://our.internmc.facebook.com/intern/diff/D129408346/) --- backends/vulkan/test/custom_ops/targets.bzl | 14 - .../vulkan/test/custom_ops/utils_test.cpp | 227 ---- .../op_tests/conv2d_gemm_dynamic_test.cpp | 8 +- backends/vulkan/test/ops/benchmark/BUCK | 9 - .../vulkan/test/ops/benchmark/CMakeLists.txt | 110 -- .../test/ops/benchmark/build_and_run.sh | 177 --- .../test/ops/benchmark/cases/add/add.cpp | 60 - .../vulkan/test/ops/benchmark/cases/add/add.h | 24 - .../cases/add/test_sets/correctness_set.cpp | 106 -- .../choose_qparams_per_row.cpp | 261 ----- .../choose_qparams_per_row.h | 43 - .../test_sets/choose_qparams_per_row_sets.cpp | 99 -- .../ops/benchmark/cases/conv1d/conv1d.cpp | 343 ------ .../test/ops/benchmark/cases/conv1d/conv1d.h | 59 - .../cases/conv1d/test_sets/dw_sets.cpp | 81 -- .../cases/conv1d/test_sets/pw_sets.cpp | 85 -- .../ops/benchmark/cases/conv2d/conv2d.cpp | 701 ----------- .../test/ops/benchmark/cases/conv2d/conv2d.h | 110 -- .../cases/conv2d/test_sets/conv2d_sets.cpp | 342 ------ .../cases/conv2d/test_sets/direct_sets.cpp | 212 ---- .../cases/conv2d/test_sets/dsr_convs_sets.cpp | 170 --- .../cases/conv2d/test_sets/dw_sets.cpp | 218 ---- .../cases/conv2d/test_sets/pw_sets.cpp | 129 --- .../cases/embedding_q4gsw/embedding_q4gsw.cpp | 200 ---- .../cases/embedding_q4gsw/embedding_q4gsw.h | 40 - .../test_sets/embedding_q4gsw_sets.cpp | 353 ------ .../vulkan/test/ops/benchmark/cases/mm/mm.cpp | 423 ------- .../vulkan/test/ops/benchmark/cases/mm/mm.h | 49 - .../benchmark/cases/mm/test_sets/mm_sets.cpp | 171 --- .../cases/q4gsw_linear/q4gsw_linear.cpp | 900 --------------- .../cases/q4gsw_linear/q4gsw_linear.h | 130 --- .../q4gsw_linear/test_sets/coopmat_sets.cpp | 154 --- .../q4gsw_linear/test_sets/debug_set.cpp | 58 - .../cases/q4gsw_linear/test_sets/fpa_sets.cpp | 264 ----- .../test_sets/q4gsw_linear_sets.cpp | 164 --- .../cases/q8csw_conv2d/q8csw_conv2d.cpp | 534 --------- .../cases/q8csw_conv2d/q8csw_conv2d.h | 32 - .../test_sets/q8csw_conv2d_sets.cpp | 202 ---- .../cases/q8csw_linear/q8csw_linear.cpp | 377 ------ .../cases/q8csw_linear/q8csw_linear.h | 43 - .../q8csw_linear/test_sets/debug_set.cpp | 56 - .../test_sets/q8csw_linear_sets.cpp | 98 -- .../benchmark/cases/q8ta/binary/binary.cpp | 282 ----- .../ops/benchmark/cases/q8ta/binary/binary.h | 45 - .../q8ta/binary/test_sets/binary_sets.cpp | 168 --- .../ops/benchmark/cases/q8ta/clone/clone.cpp | 187 --- .../ops/benchmark/cases/q8ta/clone/clone.h | 45 - .../q8ta/clone/test_sets/correctness_set.cpp | 107 -- .../cases/q8ta/clone/test_sets/debug_set.cpp | 84 -- .../benchmark/cases/q8ta/conv2d/conv2d.cpp | 605 ---------- .../ops/benchmark/cases/q8ta/conv2d/conv2d.h | 81 -- .../benchmark/cases/q8ta/conv2d/conv2d_dw.cpp | 455 -------- .../benchmark/cases/q8ta/conv2d/conv2d_pw.cpp | 433 ------- .../q8ta/conv2d/test_sets/conv2d_sets.cpp | 319 ----- .../cases/q8ta/conv2d/test_sets/debug_set.cpp | 88 -- .../q8ta/conv2d/test_sets/dw_debug_set.cpp | 79 -- .../test_sets/dw_narrow_workgroup_set.cpp | 68 -- .../cases/q8ta/conv2d/test_sets/dw_sets.cpp | 216 ---- .../q8ta/conv2d/test_sets/im2col_set.cpp | 206 ---- .../conv2d/test_sets/narrow_workgroup_set.cpp | 65 -- .../cases/q8ta/conv2d/test_sets/pw_sets.cpp | 294 ----- .../q8ta/conv2d/test_sets/scenex_sets.cpp | 101 -- .../streaming_dynamic_shrink_set.cpp | 128 --- .../conv2d/test_sets/streaming_im2col_set.cpp | 138 --- .../test_sets/unsigned_dot_selection_set.cpp | 43 - .../conv2d_transposed/conv2d_transposed.cpp | 422 ------- .../conv2d_transposed/conv2d_transposed.h | 35 - .../test_sets/conv2d_transposed_sets.cpp | 196 ---- .../benchmark/cases/q8ta/linear/linear.cpp | 293 ----- .../ops/benchmark/cases/q8ta/linear/linear.h | 41 - .../q8ta/linear/test_sets/linear_sets.cpp | 96 -- .../q8ta/pixel_shuffle/pixel_shuffle.cpp | 193 ---- .../cases/q8ta/pixel_shuffle/pixel_shuffle.h | 49 - .../test_sets/pixel_shuffle_sets.cpp | 99 -- .../test/ops/benchmark/cases/q8ta/qdq/qdq.cpp | 164 --- .../test/ops/benchmark/cases/q8ta/qdq/qdq.h | 42 - .../cases/q8ta/qdq/test_sets/qdq_sets.cpp | 202 ---- .../cases/q8ta/unary/test_sets/unary_sets.cpp | 120 -- .../ops/benchmark/cases/q8ta/unary/unary.cpp | 168 --- .../ops/benchmark/cases/q8ta/unary/unary.h | 40 - .../test/ops/benchmark/cases/sdpa/sdpa.cpp | 254 ---- .../test/ops/benchmark/cases/sdpa/sdpa.h | 53 - .../cases/sdpa/test_sets/sdpa_sets.cpp | 287 ----- .../vulkan/test/ops/benchmark/etvk_bench.cpp | 247 ---- .../test/ops/benchmark/framework/cm_utils.cpp | 139 --- .../test/ops/benchmark/framework/cm_utils.h | 21 - .../test/ops/benchmark/framework/config.cpp | 55 - .../test/ops/benchmark/framework/config.h | 31 - .../ops/benchmark/framework/conv2d_utils.cpp | 92 -- .../ops/benchmark/framework/conv2d_utils.h | 97 -- .../ops/benchmark/framework/device_info.cpp | 41 - .../ops/benchmark/framework/device_info.h | 19 - .../test/ops/benchmark/framework/labels.h | 136 --- .../test/ops/benchmark/framework/registry.cpp | 48 - .../test/ops/benchmark/framework/registry.h | 67 -- .../test/ops/benchmark/framework/results.cpp | 468 -------- .../test/ops/benchmark/framework/results.h | 235 ---- .../test/ops/benchmark/framework/runner.cpp | 757 ------------ .../test/ops/benchmark/framework/runner.h | 123 -- .../ops/benchmark/framework/test_case.cpp | 79 -- .../test/ops/benchmark/framework/test_case.h | 236 ---- .../test/ops/benchmark/framework/utils.h | 27 - .../ops/benchmark/framework/value_spec.cpp | 1024 ----------------- .../test/ops/benchmark/framework/value_spec.h | 404 ------- .../ops/benchmark/framework/weight_utils.cpp | 247 ---- .../ops/benchmark/framework/weight_utils.h | 52 - .../vulkan/test/ops/benchmark/targets.bzl | 109 -- .../test/ops/benchmark/test_ops/glsl/add.yaml | 29 - .../benchmark/test_ops/glsl/add_buffer.glsl | 39 - .../benchmark/test_ops/glsl/add_texture.glsl | 40 - .../benchmark/test_ops/glsl/float_canvas.glsl | 34 - .../glsl/packed_int32_canvas_buffer.glsl | 37 - .../glsl/packed_int32_canvas_texture3d.glsl | 37 - .../glsl/q4gsw_linear_gemv__w_4x8.glsl | 371 ------ .../glsl/q4gsw_linear_gemv__w_4x8.yaml | 40 - .../benchmark/test_ops/impl/AddPrototype.cpp | 108 -- .../benchmark/test_ops/impl/TestConv1dDW.cpp | 46 - .../benchmark/test_ops/impl/TestConv1dPW.cpp | 46 - .../benchmark/test_ops/impl/TestConv2d.cpp | 167 --- .../benchmark/test_ops/impl/TestConv2dDw.cpp | 363 ------ .../benchmark/test_ops/impl/TestConv2dPw.cpp | 61 - .../test_ops/impl/TestFpaQ4gswLinear.cpp | 871 -------------- .../test_ops/impl/TestMatmulLinear.cpp | 154 --- .../test_ops/impl/TestQ8taBinary.cpp | 96 -- .../benchmark/test_ops/impl/TestQ8taClone.cpp | 65 -- .../test_ops/impl/TestQ8taConv2d.cpp | 495 -------- .../impl/TestQ8taConv2dTransposed.cpp | 87 -- .../test_ops/impl/TestQ8taLinear.cpp | 75 -- .../test_ops/impl/TestQ8taPixelShuffle.cpp | 116 -- .../impl/TestQ8taQuantizeDequantize.cpp | 71 -- .../benchmark/test_ops/impl/TestQ8taUnary.cpp | 70 -- .../ops/benchmark/test_ops/impl/TestSDPA.cpp | 226 ---- 132 files changed, 4 insertions(+), 23321 deletions(-) delete mode 100644 backends/vulkan/test/custom_ops/utils_test.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/BUCK delete mode 100644 backends/vulkan/test/ops/benchmark/CMakeLists.txt delete mode 100755 backends/vulkan/test/ops/benchmark/build_and_run.sh delete mode 100644 backends/vulkan/test/ops/benchmark/cases/add/add.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/add/add.h delete mode 100644 backends/vulkan/test/ops/benchmark/cases/add/test_sets/correctness_set.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/choose_qparams_per_row/choose_qparams_per_row.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/choose_qparams_per_row/choose_qparams_per_row.h delete mode 100644 backends/vulkan/test/ops/benchmark/cases/choose_qparams_per_row/test_sets/choose_qparams_per_row_sets.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/conv1d/conv1d.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/conv1d/conv1d.h delete mode 100644 backends/vulkan/test/ops/benchmark/cases/conv1d/test_sets/dw_sets.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/conv1d/test_sets/pw_sets.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/conv2d/conv2d.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/conv2d/conv2d.h delete mode 100644 backends/vulkan/test/ops/benchmark/cases/conv2d/test_sets/conv2d_sets.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/conv2d/test_sets/direct_sets.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/conv2d/test_sets/dsr_convs_sets.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/conv2d/test_sets/dw_sets.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/conv2d/test_sets/pw_sets.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/embedding_q4gsw/embedding_q4gsw.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/embedding_q4gsw/embedding_q4gsw.h delete mode 100644 backends/vulkan/test/ops/benchmark/cases/embedding_q4gsw/test_sets/embedding_q4gsw_sets.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/mm/mm.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/mm/mm.h delete mode 100644 backends/vulkan/test/ops/benchmark/cases/mm/test_sets/mm_sets.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q4gsw_linear/q4gsw_linear.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q4gsw_linear/q4gsw_linear.h delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q4gsw_linear/test_sets/coopmat_sets.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q4gsw_linear/test_sets/debug_set.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q4gsw_linear/test_sets/fpa_sets.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q4gsw_linear/test_sets/q4gsw_linear_sets.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8csw_conv2d/q8csw_conv2d.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8csw_conv2d/q8csw_conv2d.h delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8csw_conv2d/test_sets/q8csw_conv2d_sets.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8csw_linear/q8csw_linear.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8csw_linear/q8csw_linear.h delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8csw_linear/test_sets/debug_set.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8csw_linear/test_sets/q8csw_linear_sets.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8ta/binary/binary.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8ta/binary/binary.h delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8ta/binary/test_sets/binary_sets.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8ta/clone/clone.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8ta/clone/clone.h delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8ta/clone/test_sets/correctness_set.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8ta/clone/test_sets/debug_set.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/conv2d.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/conv2d.h delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/conv2d_dw.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/conv2d_pw.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/conv2d_sets.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/debug_set.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/dw_debug_set.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/dw_narrow_workgroup_set.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/dw_sets.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/im2col_set.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/narrow_workgroup_set.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/pw_sets.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/scenex_sets.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/streaming_dynamic_shrink_set.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/streaming_im2col_set.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/unsigned_dot_selection_set.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d_transposed/conv2d_transposed.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d_transposed/conv2d_transposed.h delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d_transposed/test_sets/conv2d_transposed_sets.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8ta/linear/linear.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8ta/linear/linear.h delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8ta/linear/test_sets/linear_sets.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8ta/pixel_shuffle/pixel_shuffle.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8ta/pixel_shuffle/pixel_shuffle.h delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8ta/pixel_shuffle/test_sets/pixel_shuffle_sets.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8ta/qdq/qdq.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8ta/qdq/qdq.h delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8ta/qdq/test_sets/qdq_sets.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8ta/unary/test_sets/unary_sets.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8ta/unary/unary.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/q8ta/unary/unary.h delete mode 100644 backends/vulkan/test/ops/benchmark/cases/sdpa/sdpa.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/cases/sdpa/sdpa.h delete mode 100644 backends/vulkan/test/ops/benchmark/cases/sdpa/test_sets/sdpa_sets.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/etvk_bench.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/framework/cm_utils.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/framework/cm_utils.h delete mode 100644 backends/vulkan/test/ops/benchmark/framework/config.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/framework/config.h delete mode 100644 backends/vulkan/test/ops/benchmark/framework/conv2d_utils.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/framework/conv2d_utils.h delete mode 100644 backends/vulkan/test/ops/benchmark/framework/device_info.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/framework/device_info.h delete mode 100644 backends/vulkan/test/ops/benchmark/framework/labels.h delete mode 100644 backends/vulkan/test/ops/benchmark/framework/registry.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/framework/registry.h delete mode 100644 backends/vulkan/test/ops/benchmark/framework/results.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/framework/results.h delete mode 100644 backends/vulkan/test/ops/benchmark/framework/runner.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/framework/runner.h delete mode 100644 backends/vulkan/test/ops/benchmark/framework/test_case.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/framework/test_case.h delete mode 100644 backends/vulkan/test/ops/benchmark/framework/utils.h delete mode 100644 backends/vulkan/test/ops/benchmark/framework/value_spec.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/framework/value_spec.h delete mode 100644 backends/vulkan/test/ops/benchmark/framework/weight_utils.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/framework/weight_utils.h delete mode 100644 backends/vulkan/test/ops/benchmark/targets.bzl delete mode 100644 backends/vulkan/test/ops/benchmark/test_ops/glsl/add.yaml delete mode 100644 backends/vulkan/test/ops/benchmark/test_ops/glsl/add_buffer.glsl delete mode 100644 backends/vulkan/test/ops/benchmark/test_ops/glsl/add_texture.glsl delete mode 100644 backends/vulkan/test/ops/benchmark/test_ops/glsl/float_canvas.glsl delete mode 100644 backends/vulkan/test/ops/benchmark/test_ops/glsl/packed_int32_canvas_buffer.glsl delete mode 100644 backends/vulkan/test/ops/benchmark/test_ops/glsl/packed_int32_canvas_texture3d.glsl delete mode 100644 backends/vulkan/test/ops/benchmark/test_ops/glsl/q4gsw_linear_gemv__w_4x8.glsl delete mode 100644 backends/vulkan/test/ops/benchmark/test_ops/glsl/q4gsw_linear_gemv__w_4x8.yaml delete mode 100644 backends/vulkan/test/ops/benchmark/test_ops/impl/AddPrototype.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/test_ops/impl/TestConv1dDW.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/test_ops/impl/TestConv1dPW.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/test_ops/impl/TestConv2d.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/test_ops/impl/TestConv2dDw.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/test_ops/impl/TestConv2dPw.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/test_ops/impl/TestFpaQ4gswLinear.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/test_ops/impl/TestMatmulLinear.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/test_ops/impl/TestQ8taBinary.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/test_ops/impl/TestQ8taClone.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/test_ops/impl/TestQ8taConv2d.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/test_ops/impl/TestQ8taConv2dTransposed.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/test_ops/impl/TestQ8taLinear.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/test_ops/impl/TestQ8taPixelShuffle.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/test_ops/impl/TestQ8taQuantizeDequantize.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/test_ops/impl/TestQ8taUnary.cpp delete mode 100644 backends/vulkan/test/ops/benchmark/test_ops/impl/TestSDPA.cpp diff --git a/backends/vulkan/test/custom_ops/targets.bzl b/backends/vulkan/test/custom_ops/targets.bzl index 2355e6a4f2a..5a27669b10a 100644 --- a/backends/vulkan/test/custom_ops/targets.bzl +++ b/backends/vulkan/test/custom_ops/targets.bzl @@ -1,4 +1,3 @@ -load("@fbsource//tools/build_defs:platform_defs.bzl", "CXX") load("@fbsource//xplat/executorch/build:runtime_wrapper.bzl", "runtime") load( "@fbsource//xplat/executorch/backends/vulkan:targets.bzl", @@ -9,19 +8,6 @@ def define_common_targets(is_fbcode = False): if is_fbcode: return - runtime.cxx_test( - name = "utils_test", - srcs = [ - "utils_test.cpp", - ], - contacts = ["oncall+ai_infra_mobile_platform@xmail.facebook.com"], - platforms = [CXX], - deps = [ - "//executorch/backends/vulkan/test/ops/benchmark:prototyping_utils", - "//third-party/googletest:gtest_main", - ], - ) - runtime.cxx_test( name = "q8ta_conv2d_stream_plan_test", srcs = ["q8ta_conv2d_stream_plan_test.cpp"], diff --git a/backends/vulkan/test/custom_ops/utils_test.cpp b/backends/vulkan/test/custom_ops/utils_test.cpp deleted file mode 100644 index 104d585769e..00000000000 --- a/backends/vulkan/test/custom_ops/utils_test.cpp +++ /dev/null @@ -1,227 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include - -namespace executorch::vulkan::prototyping { -namespace { - -int operator_build_count = 0; -int encode_count = 0; - -class CountingEncodeNode final : public ExecuteNode { - public: - explicit CountingEncodeNode(int& count) : count_(count) {} - void encode(ComputeGraph* graph) override { - (void)graph; - ++count_; - } - - private: - int& count_; -}; - -void counting_operator(ComputeGraph& graph, const std::vector& args) { - (void)args; - ++operator_build_count; - graph.execute_nodes().emplace_back( - std::make_unique(encode_count)); -} - -REGISTER_OPERATORS { - VK_REGISTER_OP(test_etvk.counting_operator.default, counting_operator); -} - -TEST(ValueSpecTest, SetConstant_DoesNotMaterializeTensorData) { - ValueSpec value( - {16}, - vkcompute::vkapi::kFloat, - vkcompute::utils::kBuffer, - vkcompute::utils::kWidthPacked, - DataGenType::ONES); - - value.set_constant(true); - - EXPECT_FALSE(value.is_data_generated()); -} - -TEST(ValueSpecTest, ShareDataFrom_SharesImmutableTensorData) { - ValueSpec source( - {16}, - vkcompute::vkapi::kFloat, - vkcompute::utils::kBuffer, - vkcompute::utils::kWidthPacked, - DataGenType::ONES); - source.ensure_data_generated(); - - ValueSpec copy( - {16}, - vkcompute::vkapi::kFloat, - vkcompute::utils::kTexture3D, - vkcompute::utils::kChannelsPacked, - DataGenType::ONES); - copy.share_data_from(source); - - const ValueSpec& const_source = source; - const ValueSpec& const_copy = copy; - EXPECT_EQ( - const_source.get_float_data().data(), const_copy.get_float_data().data()); -} - -TEST(ValueSpecTest, MutableDataAccess_DetachesSharedTensorData) { - ValueSpec source( - {4}, - vkcompute::vkapi::kFloat, - vkcompute::utils::kBuffer, - vkcompute::utils::kWidthPacked, - DataGenType::ONES); - source.ensure_data_generated(); - - ValueSpec copy = source; - copy.get_float_data()[0] = 7.0f; - - const ValueSpec& const_source = source; - const ValueSpec& const_copy = copy; - EXPECT_FLOAT_EQ(const_source.get_float_data()[0], 1.0f); - EXPECT_FLOAT_EQ(const_copy.get_float_data()[0], 7.0f); - EXPECT_NE( - const_source.get_float_data().data(), const_copy.get_float_data().data()); -} - -TEST(ValueSpecTest, CopyConstruction_SharesImmutableReferenceData) { - ValueSpec source( - {4}, - vkcompute::vkapi::kFloat, - vkcompute::utils::kBuffer, - vkcompute::utils::kWidthPacked, - DataGenType::ZEROS); - source.get_ref_float_data() = {1.0f, 2.0f, 3.0f, 4.0f}; - - ValueSpec copy = source; - - const ValueSpec& const_source = source; - const ValueSpec& const_copy = copy; - EXPECT_EQ( - const_source.get_ref_float_data().data(), - const_copy.get_ref_float_data().data()); -} - -TEST(ValueSpecTest, MutableReferenceAccess_DetachesSharedReferenceData) { - ValueSpec source( - {2}, - vkcompute::vkapi::kFloat, - vkcompute::utils::kBuffer, - vkcompute::utils::kWidthPacked, - DataGenType::ZEROS); - source.get_ref_float_data() = {1.0f, 2.0f}; - - ValueSpec copy = source; - copy.get_ref_float_data()[0] = 9.0f; - - const ValueSpec& const_source = source; - const ValueSpec& const_copy = copy; - EXPECT_FLOAT_EQ(const_source.get_ref_float_data()[0], 1.0f); - EXPECT_FLOAT_EQ(const_copy.get_ref_float_data()[0], 9.0f); - EXPECT_NE( - const_source.get_ref_float_data().data(), - const_copy.get_ref_float_data().data()); -} - -TEST(ValueSpecTest, ConstGetter_MaterializesDeferredData) { - ValueSpec value( - {4}, - vkcompute::vkapi::kFloat, - vkcompute::utils::kBuffer, - vkcompute::utils::kWidthPacked, - DataGenType::ONES); - const ValueSpec& const_value = value; - EXPECT_FALSE(const_value.is_data_generated()); - EXPECT_FLOAT_EQ(const_value.get_float_data()[0], 1.0f); - EXPECT_FLOAT_EQ(const_value.get_float_value(), 1.0f); - EXPECT_TRUE(const_value.is_data_generated()); -} - -TEST(ValueSpecTest, ResizeData_PreservesGeneratedPattern) { - ValueSpec value( - {4}, - vkcompute::vkapi::kFloat, - vkcompute::utils::kBuffer, - vkcompute::utils::kWidthPacked, - DataGenType::ONES); - value.resize_data(8); - const ValueSpec& const_value = value; - const std::vector expected( - {1.0f, 1.0f, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f}); - EXPECT_EQ(const_value.get_float_data(), expected); -} - -TEST(ValueSpecTest, MutableDataPtr_DetachesSharedTensorData) { - ValueSpec source( - {4}, - vkcompute::vkapi::kFloat, - vkcompute::utils::kBuffer, - vkcompute::utils::kWidthPacked, - DataGenType::ONES); - source.ensure_data_generated(); - - ValueSpec copy = source; - auto* mutable_ptr = static_cast(copy.get_mutable_data_ptr()); - ASSERT_NE(mutable_ptr, nullptr); - mutable_ptr[0] = 7.0f; - - const ValueSpec& const_source = source; - const ValueSpec& const_copy = copy; - EXPECT_FLOAT_EQ(const_source.get_float_data()[0], 1.0f); - EXPECT_FLOAT_EQ(const_copy.get_float_data()[0], 7.0f); -} - -TEST(ValueSpecTest, ShareReferenceFrom_IgnoresNonTensorSpecs) { - ValueSpec scalar(3); - ValueSpec tensor( - {4}, - vkcompute::vkapi::kFloat, - vkcompute::utils::kBuffer, - vkcompute::utils::kWidthPacked, - DataGenType::ZEROS); - tensor.get_ref_float_data() = {1.0f, 2.0f, 3.0f, 4.0f}; - const void* before = tensor.get_ref_float_data().data(); - tensor.share_reference_from(scalar); - EXPECT_EQ(tensor.get_ref_float_data().data(), before); - const std::vector expected({1.0f, 2.0f, 3.0f, 4.0f}); - EXPECT_EQ(tensor.get_ref_float_data(), expected); -} - -TEST(BenchmarkGraphTest, ChainedDispatches_BuildOperatorOnce) { - if (!vkcompute::api::available()) { - return; - } - - TestCase test_case; - operator_build_count = 0; - encode_count = 0; - - BenchmarkGraph benchmark = setup_compute_graph( - test_case, - "test_etvk.counting_operator.default", - /*op_invocations_per_execute=*/8); - ComputeGraph& graph = *benchmark.graph; - - EXPECT_EQ(operator_build_count, 1); - ASSERT_EQ(graph.execute_nodes().size(), 1u); - EXPECT_EQ(benchmark.op_nodes.begin, 0u); - EXPECT_EQ(benchmark.op_nodes.end, 1u); - - // The replay path must encode the operator node once per repetition. - RepeatedGraphExecutor graph_executor( - graph, /*repetitions=*/8, benchmark.op_nodes); - EXPECT_EQ(encode_count, 8); - graph_executor.execute(); -} - -} // namespace -} // namespace executorch::vulkan::prototyping diff --git a/backends/vulkan/test/op_tests/conv2d_gemm_dynamic_test.cpp b/backends/vulkan/test/op_tests/conv2d_gemm_dynamic_test.cpp index 7b4954113ea..a033cc74847 100644 --- a/backends/vulkan/test/op_tests/conv2d_gemm_dynamic_test.cpp +++ b/backends/vulkan/test/op_tests/conv2d_gemm_dynamic_test.cpp @@ -35,10 +35,10 @@ using namespace vkcompute; // // The im2col + GEMM conv path (conv2d_gemm_impl) materializes its im2col matrix // in tiles of output-height rows bounded to a byte budget. The static op-test -// suite (test/ops/benchmark/cases/conv2d/test_conv2d.cpp) only validates a -// single shape per graph. This test exercises the path under an actual -// trigger_resize: it builds the graph at an upper-bound input shape that forces -// MULTIPLE tiles, then resizes the input across tile boundaries (so trailing +// cases only validate a single shape per graph. This test exercises the path +// under an actual trigger_resize: it builds the graph at an upper-bound input +// shape that forces MULTIPLE tiles, then resizes the input across tile +// boundaries (so trailing // tiles must no-op via the shader's `oh < H_out` guard and the scratch must // track the smaller shape) and verifies the output against a reference at every // shape. diff --git a/backends/vulkan/test/ops/benchmark/BUCK b/backends/vulkan/test/ops/benchmark/BUCK deleted file mode 100644 index f559a6f1cfe..00000000000 --- a/backends/vulkan/test/ops/benchmark/BUCK +++ /dev/null @@ -1,9 +0,0 @@ -# Any targets that should be shared between fbcode and xplat must be defined in -# targets.bzl. - -load("@fbsource//tools/build_defs:fbsource_utils.bzl", "is_fbcode") -load(":targets.bzl", "define_common_targets") - -oncall("executorch") - -define_common_targets(is_fbcode = is_fbcode()) diff --git a/backends/vulkan/test/ops/benchmark/CMakeLists.txt b/backends/vulkan/test/ops/benchmark/CMakeLists.txt deleted file mode 100644 index 522d4f95f46..00000000000 --- a/backends/vulkan/test/ops/benchmark/CMakeLists.txt +++ /dev/null @@ -1,110 +0,0 @@ -# Copyright (c) Meta Platforms, Inc. and affiliates. -# All rights reserved. -# -# This source code is licensed under the BSD-style license found in the -# LICENSE file in the root directory of this source tree. - -cmake_minimum_required(VERSION 3.19) -project(prototyping_shaders) - -if(ANDROID) - set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY BOTH) - set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE BOTH) -endif() - -find_package(executorch CONFIG REQUIRED COMPONENTS vulkan_backend) - -# Compile settings - -set(VULKAN_CXX_FLAGS "-fexceptions") -list(APPEND VULKAN_CXX_FLAGS "-DUSE_VULKAN_WRAPPER") -list(APPEND VULKAN_CXX_FLAGS "-DUSE_VULKAN_VOLK") - -message(STATUS "VULKAN_CXX_FLAGS: ${VULKAN_CXX_FLAGS}") - -# Only build if Vulkan was compiled -if(TARGET vulkan_backend) - if(NOT EXECUTORCH_ROOT) - set(EXECUTORCH_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/../../../../..) - endif() - - if(NOT PYTHON_EXECUTABLE) - set(PYTHON_EXECUTABLE python3) - endif() - - # Include this file to access executorch_target_link_options_shared_lib - include(${EXECUTORCH_ROOT}/tools/cmake/Utils.cmake) - include(${EXECUTORCH_ROOT}/backends/vulkan/cmake/ShaderLibrary.cmake) - - # Third party include paths - set(VULKAN_THIRD_PARTY_PATH ${EXECUTORCH_ROOT}/backends/vulkan/third-party) - set(VULKAN_HEADERS_PATH ${VULKAN_THIRD_PARTY_PATH}/Vulkan-Headers/include) - set(VOLK_PATH ${VULKAN_THIRD_PARTY_PATH}/volk) - set(VMA_PATH ${VULKAN_THIRD_PARTY_PATH}/VulkanMemoryAllocator) - - set(COMMON_INCLUDES ${EXECUTORCH_ROOT}/.. ${VULKAN_HEADERS_PATH} ${VOLK_PATH} - ${VMA_PATH} - ) - - # Prototyping utility files - set(PROTOTYPING_UTILS_HEADERS ${CMAKE_CURRENT_SOURCE_DIR}/framework) - set(PROTOTYPING_UTILS_CPP - ${CMAKE_CURRENT_SOURCE_DIR}/framework/cm_utils.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/framework/config.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/framework/conv2d_utils.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/framework/device_info.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/framework/registry.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/framework/results.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/framework/runner.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/framework/test_case.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/framework/value_spec.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/framework/weight_utils.cpp - ) - - # Prototyping shaders - message(STATUS "shader stuff") - set(PROTOTYPING_SHADERS_PATH ${CMAKE_CURRENT_SOURCE_DIR}/test_ops/glsl) - gen_vulkan_shader_lib_cpp(${PROTOTYPING_SHADERS_PATH}) - vulkan_shader_lib(prototyping_shaderlib ${generated_spv_cpp}) - target_compile_options(prototyping_shaderlib PRIVATE ${VULKAN_CXX_FLAGS}) - message(STATUS "done shader stuff") - - # Operator implementations library - file(GLOB OPERATOR_IMPL_SOURCES - ${CMAKE_CURRENT_SOURCE_DIR}/test_ops/impl/*.cpp - ) - add_library(operator_implementations STATIC ${OPERATOR_IMPL_SOURCES}) - target_include_directories( - operator_implementations PRIVATE ${COMMON_INCLUDES} - ) - target_link_libraries( - operator_implementations PRIVATE vulkan_backend executorch_core - prototyping_shaderlib - ) - target_compile_options(operator_implementations PRIVATE ${VULKAN_CXX_FLAGS}) - set_property(TARGET operator_implementations PROPERTY CXX_STANDARD 17) - - executorch_target_link_options_shared_lib(vulkan_backend) - executorch_target_link_options_shared_lib(operator_implementations) - - # gflags is built by the main ExecuTorch build but not installed, so find it - # in that build tree, which build_and_run.sh places five levels above this - # project's build directory. - set(gflags_DIR ${CMAKE_CURRENT_BINARY_DIR}/../../../../../third-party/gflags) - find_package(gflags REQUIRED) - - file(GLOB_RECURSE ETVK_BENCH_CASES_CPP - ${CMAKE_CURRENT_SOURCE_DIR}/cases/*.cpp - ) - add_executable( - etvk_bench ${CMAKE_CURRENT_SOURCE_DIR}/etvk_bench.cpp - ${ETVK_BENCH_CASES_CPP} ${PROTOTYPING_UTILS_CPP} - ) - target_include_directories(etvk_bench PRIVATE ${COMMON_INCLUDES}) - target_link_libraries( - etvk_bench PRIVATE vulkan_backend executorch_core prototyping_shaderlib - operator_implementations gflags - ) - target_compile_options(etvk_bench PRIVATE ${VULKAN_CXX_FLAGS}) - set_property(TARGET etvk_bench PROPERTY CXX_STANDARD 17) -endif() diff --git a/backends/vulkan/test/ops/benchmark/build_and_run.sh b/backends/vulkan/test/ops/benchmark/build_and_run.sh deleted file mode 100755 index a03aa3032a3..00000000000 --- a/backends/vulkan/test/ops/benchmark/build_and_run.sh +++ /dev/null @@ -1,177 +0,0 @@ -#!/bin/zsh - -set -eux - -# Check that we're in the executorch directory -current_dir=$(pwd) -if [[ ! "$current_dir" =~ executorch$ ]]; then - echo "Error: This script must be run from a directory ending in 'executorch'" - echo "Current directory: $current_dir" - exit 1 -fi - -# Function to configure and build main project -configure_and_build_main() { - local android_args="" - if [[ "$ANDROID_MODE" == "true" ]]; then - cmake . \ - -DCMAKE_INSTALL_PREFIX=$CMAKE_OUT_DIR \ - -DEXECUTORCH_BUILD_VULKAN=ON \ - -DCMAKE_TOOLCHAIN_FILE=$ANDROID_NDK/build/cmake/android.toolchain.cmake \ - -DANDROID_ABI=arm64-v8a \ - -DANDROID_PLATFORM=android-28 \ - -DGLSLC_PATH=$(which glslc) \ - -B$CMAKE_OUT_DIR - else - cmake . \ - -DCMAKE_INSTALL_PREFIX=$CMAKE_OUT_DIR \ - -DEXECUTORCH_BUILD_VULKAN=ON \ - -DGLSLC_PATH=$(which glslc) \ - -B$CMAKE_OUT_DIR - fi - - cmake --build $CMAKE_OUT_DIR -j$(( $(nproc 2>/dev/null || sysctl -n hw.ncpu) + 1 )) --target install - # -DCMAKE_CXX_FLAGS="-DVULKAN_DEBUG" \ -} - -# Function to build main project only -build_main() { - cmake --build $CMAKE_OUT_DIR -j$(( $(nproc 2>/dev/null || sysctl -n hw.ncpu) + 1 )) --target install -} - -# Function to configure and build tests -configure_and_build_tests() { - # Check if glslc is installed - if ! command -v glslc >/dev/null 2>&1; then - echo "Error: glslc is not installed or not found in PATH." - exit 1 - fi - - local android_args="" - if [[ "$ANDROID_MODE" == "true" ]]; then - cmake backends/vulkan/test/ops/benchmark/ \ - -DCMAKE_INSTALL_PREFIX=$CMAKE_OUT_DIR \ - -DCMAKE_BUILD_TYPE=Debug \ - -DCMAKE_TOOLCHAIN_FILE=$ANDROID_NDK/build/cmake/android.toolchain.cmake \ - -DANDROID_ABI=arm64-v8a \ - -DANDROID_PLATFORM=android-28 \ - -DGLSLC_PATH=$(which glslc) \ - -B$CMAKE_OUT_DIR/backends/vulkan/test/ops/benchmark - else - cmake backends/vulkan/test/ops/benchmark/ \ - -DCMAKE_INSTALL_PREFIX=$CMAKE_OUT_DIR \ - -DCMAKE_BUILD_TYPE=Debug \ - -DGLSLC_PATH=$(which glslc) \ - -B$CMAKE_OUT_DIR/backends/vulkan/test/ops/benchmark - fi - - cmake --build $CMAKE_OUT_DIR/backends/vulkan/test/ops/benchmark -j$(( $(nproc 2>/dev/null || sysctl -n hw.ncpu) + 1 )) --target all - -} - -build_tests() { - cmake --build $CMAKE_OUT_DIR/backends/vulkan/test/ops/benchmark -j$(( $(nproc 2>/dev/null || sysctl -n hw.ncpu) + 1 )) --target all -} - -# Function to rebuild both main and tests -rebuild_both() { - build_main - build_tests -} - -# Function to clean and rebuild everything -clean_and_rebuild() { - rm -rf $CMAKE_OUT_DIR - configure_and_build_main - configure_and_build_tests -} - -# Function to execute binary if specified -execute_binary() { - local binary_name="$1" - if [[ -n "$binary_name" ]]; then - local binary_path="$CMAKE_OUT_DIR/backends/vulkan/test/ops/benchmark/$binary_name" - echo "Executing binary: $binary_path" - - if [[ "$ANDROID_MODE" == "true" ]]; then - if [[ -f "$binary_path" ]]; then - echo "Pushing binary to Android device..." - adb push "$binary_path" /data/local/tmp/ - echo "Executing binary on Android device..." - adb shell "cd /data/local/tmp && ./$binary_name" - else - echo "Error: Binary '$binary_path' not found" - exit 1 - fi - else - if [[ -f "$binary_path" && -x "$binary_path" ]]; then - "$binary_path" - else - echo "Error: Binary '$binary_path' not found or not executable" - exit 1 - fi - fi - fi -} - -# Parse command line arguments -BINARY_TO_EXECUTE="" -ANDROID_MODE=false -CMAKE_OUT_DIR="cmake-out" - -# Check for --android flag and adjust arguments accordingly -if [[ "${1:-}" == "--android" ]]; then - ANDROID_MODE=true - CMAKE_OUT_DIR="cmake-android-out" - shift # Remove --android from arguments - echo "Android mode enabled. Using $CMAKE_OUT_DIR as build directory." -fi - -case "${1:-}" in - --rebuild|-r) - echo "Rebuilding both main project and tests..." - BINARY_TO_EXECUTE="${2:-}" - rebuild_both - execute_binary "$BINARY_TO_EXECUTE" - ;; - --rebuild1|-r1) - echo "Rebuilding main project only..." - BINARY_TO_EXECUTE="${2:-}" - build_main - execute_binary "$BINARY_TO_EXECUTE" - ;; - --rebuild2|-r2) - echo "Rebuilding tests only..." - BINARY_TO_EXECUTE="${2:-}" - build_tests - execute_binary "$BINARY_TO_EXECUTE" - ;; - --clean|-c) - echo "WARNING: This will delete the entire $CMAKE_OUT_DIR directory and rebuild everything." - echo -n "Are you sure you want to continue? (y/N): " - read -r response - if [[ "$response" =~ ^[Yy]$ ]]; then - echo "Cleaning and rebuilding everything..." - BINARY_TO_EXECUTE="${2:-}" - clean_and_rebuild - execute_binary "$BINARY_TO_EXECUTE" - else - echo "Clean operation cancelled." - exit 0 - fi - ;; - "") - echo "Running full configure and build..." - configure_and_build_main - configure_and_build_tests - ;; - *) - # If first argument doesn't match any build option, treat it as binary name - # and use default build behavior - echo "Running full configure and build..." - BINARY_TO_EXECUTE="$1" - configure_and_build_main - configure_and_build_tests - execute_binary "$BINARY_TO_EXECUTE" - ;; -esac diff --git a/backends/vulkan/test/ops/benchmark/cases/add/add.cpp b/backends/vulkan/test/ops/benchmark/cases/add/add.cpp deleted file mode 100644 index 1f54501ae05..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/add/add.cpp +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace add { - -// Custom FLOP calculator for add operation -// Add operation performs 1 FLOP (addition) per element -int64_t add_flop_calculator(const TestCase& test_case) { - // Calculate total elements from the first input tensor - int64_t total_elements = 1; - if (!test_case.empty() && test_case.num_inputs() > 0 && - test_case.inputs()[0].is_tensor()) { - const auto& sizes = test_case.inputs()[0].get_tensor_sizes(); - for (int64_t size : sizes) { - total_elements *= size; - } - } - - // Add operation: 1 FLOP per element (one addition) - return total_elements; -} - -// Reference implementation for add operator -void add_reference_compute(TestCase& test_case) { - const ValueSpec& input_a = test_case.inputs().at(0); - const ValueSpec& input_b = test_case.inputs().at(1); - - ValueSpec& output = test_case.outputs().at(0); - - if (input_a.dtype != vkapi::kFloat) { - throw std::invalid_argument("Unsupported dtype"); - } - - // Calculate number of elements - int64_t num_elements = input_a.numel(); - - auto& input_a_data = input_a.get_float_data(); - auto& input_b_data = input_b.get_float_data(); - - auto& ref_data = output.get_ref_float_data(); - ref_data.resize(num_elements); - for (int64_t i = 0; i < num_elements; ++i) { - ref_data[i] = input_a_data[i] + input_b_data[i]; - } -} - -} // namespace add -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/add/add.h b/backends/vulkan/test/ops/benchmark/cases/add/add.h deleted file mode 100644 index ae311090992..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/add/add.h +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#pragma once - -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace add { - -int64_t add_flop_calculator(const TestCase& test_case); - -void add_reference_compute(TestCase& test_case); - -} // namespace add -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/add/test_sets/correctness_set.cpp b/backends/vulkan/test/ops/benchmark/cases/add/test_sets/correctness_set.cpp deleted file mode 100644 index 78da70f50b2..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/add/test_sets/correctness_set.cpp +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace add { - -namespace { - -std::vector generate_test_cases( - const std::vector>& size_configs, - const std::vector& storage_types, - const std::vector& data_types) { - std::vector test_cases; - - // Set the data generation type as a local variable - DataGenType data_gen_type = DataGenType::ONES; - - // Generate test cases for each combination - for (const auto& sizes : size_configs) { - for (const auto& storage_type : storage_types) { - for (const auto& data_type : data_types) { - TestCase test_case; - - // Create a descriptive name for the test case - std::string shape_str = - shape_bracket(sizes) + "+" + shape_bracket(sizes); - std::string storage_str = repr_str(storage_type, utils::kWidthPacked); - std::string dtype_str = dtype_short(data_type); - std::string test_name = make_test_label( - "ACCU", dtype_str, dtype_str, shape_str, storage_str); - test_case.set_name(test_name); - - // Set the operator name for the test case - test_case.set_operator_name("etvk.add_prototype"); - - // Add two input tensors with the same size, type, storage, and data - // generation method - ValueSpec input_a( - sizes, data_type, storage_type, utils::kWidthPacked, data_gen_type); - ValueSpec input_b( - sizes, data_type, storage_type, utils::kWidthPacked, data_gen_type); - - // Add output tensor with the same size, type, and storage as inputs - // (output uses ZEROS by default) - ValueSpec output( - sizes, - data_type, - storage_type, - utils::kWidthPacked, - DataGenType::ZEROS); - - test_case.add_input_spec(input_a); - test_case.add_input_spec(input_b); - test_case.add_output_spec(output); - - test_cases.push_back(test_case); - } - } - } - - return test_cases; -} - -} // namespace - -REGISTER_TEST_CASE_SET("add", "correctness") { - // Define different input size configurations - const std::vector> size_configs = { - {1, 64, 64}, // Small square - {1, 128, 128}, // Medium square - {1, 256, 256}, // Large square - {1, 512, 512}, // Very large square - {1, 1, 1024}, // Wide tensor - {1, 1024, 1}, // Tall tensor - {32, 32, 32}, // 3D cube - {16, 128, 64}, // 3D rectangular - }; - - // Storage types to test - const std::vector storage_types = { - utils::kTexture3D, utils::kBuffer}; - - // Data types to test - const std::vector data_types = { - vkapi::kFloat, vkapi::kHalf}; - - return { - generate_test_cases(size_configs, storage_types, data_types), - add_reference_compute, - add_flop_calculator}; -} - -} // namespace add -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/choose_qparams_per_row/choose_qparams_per_row.cpp b/backends/vulkan/test/ops/benchmark/cases/choose_qparams_per_row/choose_qparams_per_row.cpp deleted file mode 100644 index fa96168c04a..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/choose_qparams_per_row/choose_qparams_per_row.cpp +++ /dev/null @@ -1,261 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include -#include -#include -#include -#include -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace choose_qparams_per_row { - -// Utility function to create a test case from a ChooseQParamsConfig -TestCase create_test_case_from_config( - const ChooseQParamsConfig& config, - utils::StorageType storage_type, - vkapi::ScalarType input_dtype) { - TestCase test_case; - - // Create a descriptive name for the test case. Op produces (scale, zero_pt) - // per row; the "output" is conceptually i8 quantization params. - bool is_perf = - !(config.num_channels < kRefDimSizeLimit && - config.channel_size < kRefDimSizeLimit); - std::string prefix = is_perf ? "PERF" : "ACCU"; - std::string in_dtype = dtype_short(input_dtype); - std::string out_dtype = "f32,i8"; // pair: (scale, zero_point) - std::string shape_str = "[" + std::to_string(config.num_channels) + "," + - std::to_string(config.channel_size) + "]"; - std::string storage_str = repr_str(storage_type, utils::kWidthPacked); - std::string test_name = - make_test_label(prefix, in_dtype, out_dtype, shape_str, storage_str); - test_case.set_name(test_name); - - // Set the operator name for the test case - std::string operator_name = "etvk." + config.op_name + ".default"; - test_case.set_operator_name(operator_name); - - // Input tensor (float) - [num_channels, channel_size] - std::vector input_size = {config.num_channels, config.channel_size}; - ValueSpec input_tensor( - input_size, - input_dtype, - storage_type, - utils::kWidthPacked, - DataGenType::RANDOM); - - if (debugging()) { - print_valuespec_data(input_tensor, "input_tensor"); - } - - // Quantization parameters - ValueSpec quant_min(config.quant_min); - ValueSpec quant_max(config.quant_max); - - // Output scale tensor (float) - [num_channels] - ValueSpec scale_out( - {config.num_channels}, - vkapi::kFloat, - utils::kTexture3D, // Always buffer as per requirement - utils::kWidthPacked, - DataGenType::ZEROS); - - // Output zero_point tensor (int8) - [num_channels] - ValueSpec zero_point_out( - {config.num_channels}, - vkapi::kChar, // int8 for quantized zero point - utils::kTexture3D, // Always buffer as per requirement - utils::kWidthPacked, - DataGenType::ZEROS); - - // Add all specs to test case - test_case.add_input_spec(input_tensor); - test_case.add_input_spec(quant_min); - test_case.add_input_spec(quant_max); - test_case.add_output_spec(scale_out); - test_case.add_output_spec(zero_point_out); - - return test_case; -} - -int64_t choose_qparams_per_channel_flop_calculator(const TestCase& test_case) { - // Get input dimensions - const auto& input_sizes = test_case.inputs()[0].get_tensor_sizes(); - int64_t num_channels = input_sizes[0]; - int64_t channel_size = input_sizes[1]; - - // Calculate FLOPs for choose_qparams_per_channel operation - // Each channel requires: - // - Min/max finding: approximately 2 * channel_size comparisons - // - Scale calculation: ~5 operations (division, min/max operations) - // - Zero point calculation: ~10 operations (multiple arithmetic operations) - int64_t ops_per_channel = 2 * channel_size + 15; // Simplified estimate - - int64_t flop = num_channels * ops_per_channel; - - return flop; -} - -static constexpr float SMALL_SCALE_THRESHOLD = 6.1e-5f; - -// CPU reference implementation matching the behavior from op_choose_qparams.cpp -void calculate_scale_and_zero_point_reference( - float min_val, - float max_val, - int32_t qmin, - int32_t qmax, - float& scale, - int32_t& zero_point) { - // Extend the [min, max] interval to ensure that it contains 0 - min_val = std::min(min_val, 0.0f); - max_val = std::max(max_val, 0.0f); - - // Use double precision for intermediate computation but use single precision - // in final number to reflect the actual number used during quantization. - double scale_double = - (static_cast(max_val) - min_val) / (qmax - qmin); - - // If scale is 0 or too small so its reciprocal is infinity, we arbitrary - // adjust the scale to 0.1 . We want to avoid scale's reciprocal being - // infinity because some of fbgemm code pre-computes scale's reciprocal to do - // multiplication instead of division in the time critical part of code. - if (static_cast(scale_double) == 0.0f || - std::isinf(1.0f / static_cast(scale_double))) { - scale_double = 0.1; - } - - // Cut off small scale - if (scale_double < SMALL_SCALE_THRESHOLD) { - float org_scale = static_cast(scale_double); - scale_double = SMALL_SCALE_THRESHOLD; - // Adjust the min and max based on the new scale - if (min_val == 0.0f) { - max_val = SMALL_SCALE_THRESHOLD * (qmax - qmin); - } else if (max_val == 0.0f) { - min_val = -SMALL_SCALE_THRESHOLD * (qmax - qmin); - } else { - float amplifier = SMALL_SCALE_THRESHOLD / org_scale; - min_val *= amplifier; - max_val *= amplifier; - } - } - - // Zero-point computation. - // First the initial floating-point computation. The zero-point can be - // determined from solving an affine equation for any known pair - // (real value, corresponding quantized value). - // We know two such pairs: (rmin, qmin) and (rmax, qmax). - // The arithmetic error on the zero point computed from either pair - // will be roughly machine_epsilon * (sum of absolute values of terms) - // so we want to use the variant that adds the smaller terms. - double zero_point_from_min = qmin - min_val / scale_double; - double zero_point_from_max = qmax - max_val / scale_double; - double zero_point_from_min_error = - std::abs(qmin) - std::abs(min_val / scale_double); - double zero_point_from_max_error = - std::abs(qmax) - std::abs(max_val / scale_double); - double initial_zero_point = - zero_point_from_min_error < zero_point_from_max_error - ? zero_point_from_min - : zero_point_from_max; - - // Now we need to nudge the zero point to be an integer - // (our zero points are integer, and this is motivated by the requirement - // to be able to represent the real value "0" exactly as a quantized value, - // which is required in multiple places, for example in Im2col with zero - // padding). - int32_t nudged_zero_point = 0; - if (initial_zero_point < qmin) { - nudged_zero_point = qmin; - } else if (initial_zero_point > qmax) { - nudged_zero_point = qmax; - } else { - nudged_zero_point = static_cast( - std::nearbyint(static_cast(initial_zero_point))); - } - - scale = static_cast(scale_double); - zero_point = nudged_zero_point; -} - -// Reference implementation for choose_qparams_per_channel -void choose_qparams_per_channel_reference_impl(TestCase& test_case) { - int32_t idx = 0; - const ValueSpec& input_spec = test_case.inputs()[idx++]; - const ValueSpec& quant_min_spec = test_case.inputs()[idx++]; - const ValueSpec& quant_max_spec = test_case.inputs()[idx++]; - - // Extract output specifications - ValueSpec& scale_out_spec = test_case.outputs()[0]; - ValueSpec& zero_point_out_spec = test_case.outputs()[1]; - - // Get tensor dimensions - auto input_sizes = - input_spec.get_tensor_sizes(); // [num_channels, channel_size] - int64_t num_channels = input_sizes[0]; - int64_t channel_size = input_sizes[1]; - - // Skip for large tensors since computation time will be extremely slow - if (num_channels > kRefDimSizeLimit || channel_size > kRefDimSizeLimit) { - throw std::invalid_argument( - "One or more dimensions (num_channels, channel_size) exceed the allowed limit for reference implementation."); - } - - if (input_spec.dtype != vkapi::kFloat) { - throw std::invalid_argument("Unsupported dtype"); - } - - // Get raw data pointers - auto& input_data = input_spec.get_float_data(); - int32_t quant_min = quant_min_spec.get_int_value(); - int32_t quant_max = quant_max_spec.get_int_value(); - - // Prepare output data - auto& scale_ref_data = scale_out_spec.get_ref_float_data(); - auto& zero_point_ref_data = zero_point_out_spec.get_ref_int8_data(); - scale_ref_data.resize(num_channels); - zero_point_ref_data.resize(num_channels); - - // Process each channel - for (int64_t channel = 0; channel < num_channels; ++channel) { - // Find min and max for this channel - float min_val = std::numeric_limits::max(); - float max_val = std::numeric_limits::lowest(); - - for (int64_t i = 0; i < channel_size; ++i) { - int64_t input_idx = channel * channel_size + i; - float val = input_data[input_idx]; - min_val = std::min(min_val, val); - max_val = std::max(max_val, val); - } - - // Calculate scale and zero point for this channel - float scale; - int32_t zero_point; - calculate_scale_and_zero_point_reference( - min_val, max_val, quant_min, quant_max, scale, zero_point); - - // Store results (cast zero_point to int8) - scale_ref_data[channel] = scale; - zero_point_ref_data[channel] = static_cast(zero_point); - } -} - -void reference_impl(TestCase& test_case) { - choose_qparams_per_channel_reference_impl(test_case); -} - -} // namespace choose_qparams_per_row -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/choose_qparams_per_row/choose_qparams_per_row.h b/backends/vulkan/test/ops/benchmark/cases/choose_qparams_per_row/choose_qparams_per_row.h deleted file mode 100644 index 88ce7f05028..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/choose_qparams_per_row/choose_qparams_per_row.h +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#pragma once - -#include -#include - -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace choose_qparams_per_row { - -constexpr int64_t kRefDimSizeLimit = 2050; - -// ChooseQParams configuration struct -struct ChooseQParamsConfig { - int64_t num_channels; // Height dimension (number of channels) - int64_t channel_size; // Width dimension (size per channel) - int32_t quant_min = -128; - int32_t quant_max = 127; - std::string test_case_name = "placeholder"; - std::string op_name = "choose_qparams_per_row"; -}; - -TestCase create_test_case_from_config( - const ChooseQParamsConfig& config, - utils::StorageType storage_type, - vkapi::ScalarType input_dtype); - -void reference_impl(TestCase& test_case); - -int64_t choose_qparams_per_channel_flop_calculator(const TestCase& test_case); - -} // namespace choose_qparams_per_row -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/choose_qparams_per_row/test_sets/choose_qparams_per_row_sets.cpp b/backends/vulkan/test/ops/benchmark/cases/choose_qparams_per_row/test_sets/choose_qparams_per_row_sets.cpp deleted file mode 100644 index 6b95e4ff303..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/choose_qparams_per_row/test_sets/choose_qparams_per_row_sets.cpp +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace choose_qparams_per_row { - -namespace { - -std::vector generate_test_cases( - const std::vector& configs, - const std::vector& storage_types) { - std::vector test_cases; - - for (auto config : configs) { - std::string prefix = (config.num_channels < kRefDimSizeLimit && - config.channel_size < kRefDimSizeLimit) - ? "correctness_" - : "performance_"; - std::string generated_test_case_name = prefix + - std::to_string(config.num_channels) + "_" + - std::to_string(config.channel_size); - - config.test_case_name = generated_test_case_name; - - for (const auto& storage_type : storage_types) { - test_cases.push_back( - create_test_case_from_config(config, storage_type, vkapi::kFloat)); - } - } - - return test_cases; -} - -} // namespace - -REGISTER_TEST_CASE_SET("choose_qparams_per_row", "correctness") { - const std::vector configs = { - {4, 16}, - {8, 32}, - {16, 64}, - {32, 128}, - {64, 256}, - {128, 512}, - {1, 512}, - {256, 1024}, - {512, 2048}, - {1, 2048}, - }; - // Test with different storage types - const std::vector storage_types = { - utils::kTexture3D, utils::kBuffer}; - return { - generate_test_cases(configs, storage_types), - reference_impl, - choose_qparams_per_channel_flop_calculator}; -} - -REGISTER_TEST_CASE_SET("choose_qparams_per_row", "debug") { - const std::vector configs = { - // Single simple configuration for debugging - {4, 8}, - }; - // Test with different storage types - const std::vector storage_types = { - utils::kTexture3D, utils::kBuffer}; - return { - generate_test_cases(configs, storage_types), - reference_impl, - choose_qparams_per_channel_flop_calculator}; -} - -REGISTER_TEST_CASE_SET("choose_qparams_per_row", "performance") { - const std::vector configs = { - // Performance cases - {1, 8096}, - }; - // Test with different storage types - const std::vector storage_types = { - utils::kTexture3D, utils::kBuffer}; - return { - generate_test_cases(configs, storage_types), - reference_impl, - choose_qparams_per_channel_flop_calculator}; -} - -} // namespace choose_qparams_per_row -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/conv1d/conv1d.cpp b/backends/vulkan/test/ops/benchmark/cases/conv1d/conv1d.cpp deleted file mode 100644 index 136f9fa6cf2..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/conv1d/conv1d.cpp +++ /dev/null @@ -1,343 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace conv1d { - -TestCase create_conv1d_dw_test_case( - const Conv1dDWConfig& config, - vkapi::ScalarType dtype, - utils::StorageType storage_type) { - TestCase test_case; - - bool is_perf = config.C > kRefDimSizeLimit || config.L > kRefDimSizeLimit; - - std::string prefix = is_perf ? "PERF" : "ACCU"; - std::string storage_str = storage_type_abbrev(storage_type) + "(HP)"; - std::string dtype_str = dtype_short(dtype); - std::string bias_str = config.has_bias ? "+bias" : ""; - - int64_t L_out = - (config.L + 2 * config.padding - config.dilation * (config.K - 1) - 1) / - config.stride + - 1; - - std::string shape = "[" + std::to_string(config.N) + "," + - std::to_string(config.C) + "," + std::to_string(config.L) + "] k" + - std::to_string(config.K) + " s" + std::to_string(config.stride) + " p" + - std::to_string(config.padding) + " d" + std::to_string(config.dilation); - - std::string name = make_test_label( - prefix, dtype_str, dtype_str, shape, storage_str, bias_str); - - test_case.set_name(name); - test_case.set_operator_name("test_etvk.test_conv1d_dw.default"); - - // Input: [N, C, L] height-packed - ValueSpec input( - {config.N, config.C, config.L}, - dtype, - storage_type, - utils::kHeightPacked, - DataGenType::RANDOM); - test_case.add_input_spec(input); - - // Weight: [C, 1, K] height-packed, constant - ValueSpec weight( - {config.C, 1, config.K}, - dtype, - storage_type, - utils::kHeightPacked, - DataGenType::RANDOM); - weight.set_constant(true); - test_case.add_input_spec(weight); - - // Bias: [C] or None - if (config.has_bias) { - ValueSpec bias( - {config.C}, - dtype, - storage_type, - utils::kWidthPacked, - DataGenType::RANDOM); - bias.set_constant(true); - test_case.add_input_spec(bias); - } else { - ValueSpec none_bias(static_cast(0)); - none_bias.set_none(true); - test_case.add_input_spec(none_bias); - } - - // stride - test_case.add_input_spec( - ValueSpec(std::vector{static_cast(config.stride)})); - // padding - test_case.add_input_spec( - ValueSpec(std::vector{static_cast(config.padding)})); - // dilation - test_case.add_input_spec( - ValueSpec(std::vector{static_cast(config.dilation)})); - // groups = C (depthwise) - test_case.add_input_spec(ValueSpec(static_cast(config.C))); - - // Output: [N, C, L_out] height-packed - ValueSpec output( - {config.N, config.C, L_out}, - dtype, - storage_type, - utils::kHeightPacked, - DataGenType::ZEROS); - test_case.add_output_spec(output); - - if (dtype == vkapi::kHalf) { - test_case.set_abs_tolerance(1e-1f); - test_case.set_rel_tolerance(1e-2f); - } else { - test_case.set_abs_tolerance(1e-3f); - test_case.set_rel_tolerance(1e-3f); - } - - test_case.set_shader_filter({"nchw_to", "to_nchw", "view_copy"}); - - return test_case; -} - -TestCase create_conv1d_pw_test_case( - const Conv1dPWConfig& config, - vkapi::ScalarType dtype, - utils::StorageType storage_type) { - TestCase test_case; - - bool is_perf = config.C_in > kRefDimSizeLimit || - config.C_out > kRefDimSizeLimit || config.L > kRefDimSizeLimit; - - std::string prefix = is_perf ? "PERF" : "ACCU"; - std::string storage_str = storage_type_abbrev(storage_type) + "(HP)"; - std::string dtype_str = dtype_short(dtype); - - std::string bias_str = config.has_bias ? "+bias" : ""; - - std::string shape = "[" + std::to_string(config.N) + "," + - std::to_string(config.C_in) + "," + std::to_string(config.L) + "]x[" + - std::to_string(config.C_out) + "," + std::to_string(config.C_in) + ",1]"; - - std::string name = make_test_label( - prefix, dtype_str, dtype_str, shape, storage_str, bias_str); - - test_case.set_name(name); - test_case.set_operator_name("test_etvk.test_conv1d_pw.default"); - - // Input: [N, C_in, L] height-packed - ValueSpec input( - {config.N, config.C_in, config.L}, - dtype, - storage_type, - utils::kHeightPacked, - DataGenType::RANDOM); - test_case.add_input_spec(input); - - // Weight: [C_out, C_in, 1] height-packed, constant - ValueSpec weight( - {config.C_out, config.C_in, 1}, - dtype, - storage_type, - utils::kHeightPacked, - DataGenType::RANDOM); - weight.set_constant(true); - test_case.add_input_spec(weight); - - // Bias: [C_out] or None - if (config.has_bias) { - ValueSpec bias( - {config.C_out}, - dtype, - storage_type, - utils::kWidthPacked, - DataGenType::RANDOM); - bias.set_constant(true); - test_case.add_input_spec(bias); - } else { - ValueSpec none_bias(static_cast(0)); - none_bias.set_none(true); - test_case.add_input_spec(none_bias); - } - - // stride = [1] - test_case.add_input_spec(ValueSpec(std::vector{1})); - // padding = [0] - test_case.add_input_spec(ValueSpec(std::vector{0})); - // dilation = [1] - test_case.add_input_spec(ValueSpec(std::vector{1})); - // groups = 1 - test_case.add_input_spec(ValueSpec(static_cast(1))); - - // Output: [N, C_out, L] height-packed - ValueSpec output( - {config.N, config.C_out, config.L}, - dtype, - storage_type, - utils::kHeightPacked, - DataGenType::ZEROS); - test_case.add_output_spec(output); - - if (dtype == vkapi::kHalf) { - test_case.set_abs_tolerance(1e-1f); - test_case.set_rel_tolerance(1e-2f); - } else { - test_case.set_abs_tolerance(1e-3f); - test_case.set_rel_tolerance(1e-3f); - } - - test_case.set_shader_filter({"nchw_to", "to_nchw", "view_copy"}); - - return test_case; -} - -int64_t conv1d_dw_flop_calculator(const TestCase& test_case) { - auto in_sizes = test_case.inputs()[0].get_tensor_sizes(); - auto w_sizes = test_case.inputs()[1].get_tensor_sizes(); - auto out_sizes = test_case.outputs()[0].get_tensor_sizes(); - - const int64_t N = in_sizes[0]; - const int64_t C = in_sizes[1]; - const int64_t K = w_sizes[2]; - const int64_t L_out = out_sizes[2]; - - return 2 * N * C * L_out * K; -} - -int64_t conv1d_pw_flop_calculator(const TestCase& test_case) { - auto in_sizes = test_case.inputs()[0].get_tensor_sizes(); - auto w_sizes = test_case.inputs()[1].get_tensor_sizes(); - - int64_t N = in_sizes[0]; - int64_t C_in = in_sizes[1]; - int64_t L = in_sizes[2]; - int64_t C_out = w_sizes[0]; - - return 2 * N * C_in * C_out * L; -} - -void conv1d_dw_reference_impl(TestCase& test_case) { - const auto& input_spec = test_case.inputs()[0]; - const auto& weight_spec = test_case.inputs()[1]; - const auto& bias_spec = test_case.inputs()[2]; - const auto& stride_spec = test_case.inputs()[3]; - const auto& padding_spec = test_case.inputs()[4]; - const auto& dilation_spec = test_case.inputs()[5]; - ValueSpec& output = test_case.outputs()[0]; - - if (input_spec.dtype != vkapi::kFloat) { - throw std::invalid_argument("Reference only supports float"); - } - - auto in_sizes = input_spec.get_tensor_sizes(); - auto w_sizes = weight_spec.get_tensor_sizes(); - auto out_sizes = output.get_tensor_sizes(); - - const int64_t N = in_sizes[0]; - const int64_t C = in_sizes[1]; - const int64_t L_in = in_sizes[2]; - const int64_t K = w_sizes[2]; - const int64_t L_out = out_sizes[2]; - - const int64_t stride = stride_spec.get_int_list()[0]; - const int64_t padding = padding_spec.get_int_list()[0]; - const int64_t dilation = dilation_spec.get_int_list()[0]; - - const auto& in_data = input_spec.get_float_data(); - const auto& w_data = weight_spec.get_float_data(); - auto& ref_data = output.get_ref_float_data(); - ref_data.resize(N * C * L_out, 0.0f); - - for (int64_t n = 0; n < N; ++n) { - for (int64_t c = 0; c < C; ++c) { - for (int64_t l = 0; l < L_out; ++l) { - float sum = 0.0f; - for (int64_t k = 0; k < K; ++k) { - const int64_t l_in = l * stride - padding + k * dilation; - if (l_in >= 0 && l_in < L_in) { - sum += in_data[n * C * L_in + c * L_in + l_in] * w_data[c * K + k]; - } - } - ref_data[n * C * L_out + c * L_out + l] = sum; - } - } - } - - if (!bias_spec.is_none()) { - const auto& bias_data = bias_spec.get_float_data(); - for (int64_t n = 0; n < N; ++n) { - for (int64_t c = 0; c < C; ++c) { - for (int64_t l = 0; l < L_out; ++l) { - ref_data[n * C * L_out + c * L_out + l] += bias_data[c]; - } - } - } - } -} - -void conv1d_pw_reference_impl(TestCase& test_case) { - const auto& input_spec = test_case.inputs()[0]; - const auto& weight_spec = test_case.inputs()[1]; - const auto& bias_spec = test_case.inputs()[2]; - ValueSpec& output = test_case.outputs()[0]; - - if (input_spec.dtype != vkapi::kFloat) { - throw std::invalid_argument("Reference only supports float"); - } - - auto in_sizes = input_spec.get_tensor_sizes(); - auto w_sizes = weight_spec.get_tensor_sizes(); - - int64_t N = in_sizes[0]; - int64_t C_in = in_sizes[1]; - int64_t L = in_sizes[2]; - int64_t C_out = w_sizes[0]; - - const auto& in_data = input_spec.get_float_data(); - const auto& w_data = weight_spec.get_float_data(); - auto& ref_data = output.get_ref_float_data(); - ref_data.resize(N * C_out * L, 0.0f); - - // input is NCHW-contiguous: [N, C_in, L] - // weight is [C_out, C_in, 1] - for (int64_t n = 0; n < N; ++n) { - for (int64_t oc = 0; oc < C_out; ++oc) { - for (int64_t l = 0; l < L; ++l) { - float sum = 0.0f; - for (int64_t ic = 0; ic < C_in; ++ic) { - sum += in_data[n * C_in * L + ic * L + l] * w_data[oc * C_in + ic]; - } - ref_data[n * C_out * L + oc * L + l] = sum; - } - } - } - - if (!bias_spec.is_none()) { - const auto& bias_data = bias_spec.get_float_data(); - for (int64_t n = 0; n < N; ++n) { - for (int64_t oc = 0; oc < C_out; ++oc) { - for (int64_t l = 0; l < L; ++l) { - ref_data[n * C_out * L + oc * L + l] += bias_data[oc]; - } - } - } - } -} - -} // namespace conv1d -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/conv1d/conv1d.h b/backends/vulkan/test/ops/benchmark/cases/conv1d/conv1d.h deleted file mode 100644 index c915e9c91d9..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/conv1d/conv1d.h +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#pragma once - -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace conv1d { - -constexpr int64_t kRefDimSizeLimit = 256; - -struct Conv1dDWConfig { - int64_t N; - int64_t C; - int64_t L; - int64_t K; - int64_t stride; - int64_t padding; - int64_t dilation; - bool has_bias; -}; - -struct Conv1dPWConfig { - int64_t N; - int64_t C_in; - int64_t C_out; - int64_t L; - bool has_bias; -}; - -TestCase create_conv1d_dw_test_case( - const Conv1dDWConfig& config, - vkapi::ScalarType dtype, - utils::StorageType storage_type); - -TestCase create_conv1d_pw_test_case( - const Conv1dPWConfig& config, - vkapi::ScalarType dtype, - utils::StorageType storage_type); - -void conv1d_dw_reference_impl(TestCase& test_case); - -void conv1d_pw_reference_impl(TestCase& test_case); - -int64_t conv1d_dw_flop_calculator(const TestCase& test_case); - -int64_t conv1d_pw_flop_calculator(const TestCase& test_case); - -} // namespace conv1d -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/conv1d/test_sets/dw_sets.cpp b/backends/vulkan/test/ops/benchmark/cases/conv1d/test_sets/dw_sets.cpp deleted file mode 100644 index 24ed642e6aa..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/conv1d/test_sets/dw_sets.cpp +++ /dev/null @@ -1,81 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace conv1d { - -namespace { - -std::vector generate_dw_test_cases( - const std::vector& configs, - const std::vector& storage_types, - const std::vector& dtypes) { - std::vector test_cases; - - for (const auto& cfg : configs) { - for (auto st : storage_types) { - for (auto dtype : dtypes) { - test_cases.push_back(create_conv1d_dw_test_case(cfg, dtype, st)); - } - } - } - - return test_cases; -} - -} // namespace - -REGISTER_TEST_CASE_SET("conv1d", "dw_correctness") { - // Accuracy shapes - const std::vector configs = { - // {N, C, L, K, stride, padding, dilation, has_bias} - {1, 16, 64, 3, 1, 1, 1, false}, - {1, 32, 128, 5, 1, 2, 1, true}, - {1, 64, 32, 3, 2, 1, 1, false}, - {2, 16, 64, 3, 1, 1, 1, true}, - {1, 16, 64, 7, 1, 3, 2, false}, - // Non-aligned channel counts (not a multiple of 4) - {1, 5, 64, 3, 1, 1, 1, false}, - {1, 5, 64, 3, 1, 1, 1, true}, - {1, 7, 32, 5, 1, 2, 1, false}, - {1, 13, 48, 3, 2, 1, 1, true}, - {2, 7, 64, 3, 1, 1, 1, false}, - }; - const std::vector storage_types = { - utils::kTexture3D, utils::kBuffer}; - const std::vector dtypes = {vkapi::kFloat}; - return { - generate_dw_test_cases(configs, storage_types, dtypes), - conv1d_dw_reference_impl, - conv1d_dw_flop_calculator}; -} - -REGISTER_TEST_CASE_SET("conv1d", "dw_performance") { - // Performance shapes (half + float) - const std::vector configs = { - {1, 256, 1024, 3, 1, 1, 1, false}, - {1, 512, 2048, 5, 1, 2, 1, true}, - {1, 128, 4096, 31, 1, 15, 1, false}, - }; - const std::vector storage_types = { - utils::kTexture3D, utils::kBuffer}; - const std::vector dtypes = {vkapi::kFloat, vkapi::kHalf}; - return { - generate_dw_test_cases(configs, storage_types, dtypes), - conv1d_dw_reference_impl, - conv1d_dw_flop_calculator}; -} - -} // namespace conv1d -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/conv1d/test_sets/pw_sets.cpp b/backends/vulkan/test/ops/benchmark/cases/conv1d/test_sets/pw_sets.cpp deleted file mode 100644 index 67f6b873425..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/conv1d/test_sets/pw_sets.cpp +++ /dev/null @@ -1,85 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace conv1d { - -namespace { - -std::vector generate_pw_test_cases( - const std::vector& configs, - const std::vector& storage_types, - const std::vector& dtypes) { - std::vector test_cases; - - for (const auto& cfg : configs) { - for (auto st : storage_types) { - for (auto dtype : dtypes) { - test_cases.push_back(create_conv1d_pw_test_case(cfg, dtype, st)); - } - } - } - - return test_cases; -} - -} // namespace - -REGISTER_TEST_CASE_SET("conv1d", "pw_correctness") { - // Accuracy shapes (float, small) - const std::vector configs = { - {1, 16, 32, 64, false}, - {1, 16, 32, 64, true}, - {1, 32, 16, 128, false}, - {1, 32, 16, 128, true}, - {1, 64, 64, 32, false}, - {1, 128, 256, 16, true}, - {2, 16, 32, 64, false}, - {2, 16, 32, 64, true}, - // Non-aligned channel counts (not a multiple of 4) - {1, 5, 7, 64, false}, - {1, 5, 7, 64, true}, - {1, 13, 17, 48, false}, - {1, 13, 17, 48, true}, - {1, 7, 5, 32, false}, - {2, 5, 13, 64, true}, - }; - const std::vector storage_types = { - utils::kTexture3D, utils::kBuffer}; - const std::vector dtypes = {vkapi::kFloat}; - return { - generate_pw_test_cases(configs, storage_types, dtypes), - conv1d_pw_reference_impl, - conv1d_pw_flop_calculator}; -} - -REGISTER_TEST_CASE_SET("conv1d", "pw_performance") { - // Performance shapes (half + float) - const std::vector configs = { - {1, 256, 512, 1024, false}, - {1, 256, 512, 1024, true}, - {1, 512, 256, 2048, false}, - {1, 128, 128, 4096, true}, - }; - const std::vector storage_types = { - utils::kTexture3D, utils::kBuffer}; - const std::vector dtypes = {vkapi::kFloat, vkapi::kHalf}; - return { - generate_pw_test_cases(configs, storage_types, dtypes), - conv1d_pw_reference_impl, - conv1d_pw_flop_calculator}; -} - -} // namespace conv1d -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/conv2d/conv2d.cpp b/backends/vulkan/test/ops/benchmark/cases/conv2d/conv2d.cpp deleted file mode 100644 index 3d40d484833..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/conv2d/conv2d.cpp +++ /dev/null @@ -1,701 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace conv2d { - -namespace { - -int64_t calc_out_size( - int64_t in_size, - int64_t kernel_size, - int64_t stride, - int64_t padding, - int64_t dilation) { - return (in_size + 2 * padding - dilation * (kernel_size - 1) - 1) / stride + - 1; -} - -} // namespace - -// Shared perf/skip classification used by both create_conv2d_test_case (to tag -// PERF vs ACCU) and conv2d_reference_impl (to gate the large-K FP16 reference -// check). A shape is "perf" if any dimension reaches kRefDimSizeLimit; the -// boundary is inclusive (>=) so a 64-wide dim counts as perf — FP16 -// accumulation error at K = K_h * K_w * C_in for such shapes can exceed the -// half tolerance and false-fail. Keep both call sites on this single helper to -// avoid the two predicates drifting apart. -bool conv2d_is_perf_shape(int64_t C_in, int64_t C_out, int64_t H, int64_t W) { - return C_in >= kRefDimSizeLimit || C_out >= kRefDimSizeLimit || - H >= kRefDimSizeLimit || W >= kRefDimSizeLimit; -} - -TestCase create_conv2d_test_case( - const Conv2dTestConfig& config, - vkapi::ScalarType dtype, - utils::StorageType storage_type, - utils::GPUMemoryLayout memory_layout, - const std::string& impl_selector) { - TestCase test_case; - - bool is_perf = conv2d_is_perf_shape( - config.dims.C, config.C_out, config.dims.H, config.dims.W); - - std::string prefix = is_perf ? "PERF" : "ACCU"; - std::string storage_str = repr_str(storage_type, memory_layout); - std::string dtype_str = dtype_short(dtype); - std::string bias_str = config.has_bias ? "+bias" : ""; - - if (config.groups <= 0 || config.dims.C % config.groups != 0 || - config.C_out % config.groups != 0) { - throw std::invalid_argument( - "Convolution channels must be divisible by groups"); - } - - int64_t H_out = calc_out_size( - config.dims.H, - config.kernel.h, - config.stride.h, - config.padding.h, - config.dilation.h); - int64_t W_out = calc_out_size( - config.dims.W, - config.kernel.w, - config.stride.w, - config.padding.w, - config.dilation.w); - - const auto pair_label = [](int64_t h, int64_t w) { - return std::to_string(h) + (h == w ? "" : "x" + std::to_string(w)); - }; - std::string shape = "[" + std::to_string(config.dims.N) + "," + - std::to_string(config.dims.C) + "," + std::to_string(config.dims.H) + - "," + std::to_string(config.dims.W) + "]->[" + - std::to_string(config.C_out) + "] k" + std::to_string(config.kernel.h) + - "x" + std::to_string(config.kernel.w) + " s" + - pair_label(config.stride.h, config.stride.w) + " p" + - pair_label(config.padding.h, config.padding.w) + " d" + - pair_label(config.dilation.h, config.dilation.w); - - std::string suffix = bias_str; - if (config.groups != 1) { - suffix += (suffix.empty() ? "" : " ") + std::string("g") + - std::to_string(config.groups); - } - if (config.clamp) { - suffix += (suffix.empty() ? "" : " ") + std::string("clamp[") + - std::to_string(config.clamp->min) + "," + - std::to_string(config.clamp->max) + "]"; - } - if (!impl_selector.empty()) { - if (!suffix.empty()) { - suffix += " "; - } - suffix += "[" + impl_selector + "]"; - } - - std::string name = - make_test_label(prefix, dtype_str, dtype_str, shape, storage_str, suffix); - - test_case.set_name(name); - test_case.set_operator_name("test_etvk.test_conv2d.default"); - - // Input tensor [N, C_in, H, W] - ValueSpec input( - {config.dims.N, config.dims.C, config.dims.H, config.dims.W}, - dtype, - storage_type, - memory_layout, - DataGenType::RANDOM); - - // Weight tensor [C_out, C_in / groups, K_h, K_w] - constant - ValueSpec weight( - {config.C_out, - config.dims.C / config.groups, - config.kernel.h, - config.kernel.w}, - dtype, - storage_type, - memory_layout, - DataGenType::RANDOM); - weight.set_constant(true); - - test_case.add_input_spec(input); - test_case.add_input_spec(weight); - - // Bias (or none) - if (config.has_bias) { - ValueSpec bias( - {config.C_out}, - dtype, - storage_type, - memory_layout, - DataGenType::RANDOM); - bias.set_constant(true); - test_case.add_input_spec(bias); - } else { - ValueSpec none_bias(static_cast(0)); - none_bias.set_none(true); - test_case.add_input_spec(none_bias); - } - - // stride_h, stride_w, padding_h, padding_w, dilation_h, dilation_w - test_case.add_input_spec(ValueSpec(static_cast(config.stride.h))); - test_case.add_input_spec(ValueSpec(static_cast(config.stride.w))); - test_case.add_input_spec(ValueSpec(static_cast(config.padding.h))); - test_case.add_input_spec(ValueSpec(static_cast(config.padding.w))); - test_case.add_input_spec(ValueSpec(static_cast(config.dilation.h))); - test_case.add_input_spec(ValueSpec(static_cast(config.dilation.w))); - - // impl_selector string - test_case.add_input_spec(ValueSpec::make_string(impl_selector)); - test_case.add_input_spec(ValueSpec(static_cast(config.groups))); - test_case.add_input_spec(ValueSpec(config.clamp.has_value())); - test_case.add_input_spec(ValueSpec(config.clamp ? config.clamp->min : 0.0f)); - test_case.add_input_spec(ValueSpec(config.clamp ? config.clamp->max : 0.0f)); - - // Output tensor [N, C_out, H_out, W_out] - ValueSpec output( - {config.dims.N, config.C_out, H_out, W_out}, - dtype, - storage_type, - memory_layout, - DataGenType::ZEROS); - test_case.add_output_spec(output); - - if (dtype == vkapi::kHalf) { - test_case.set_abs_tolerance(1e-1f); - test_case.set_rel_tolerance(1e-2f); - } else { - test_case.set_abs_tolerance(1e-3f); - test_case.set_rel_tolerance(1e-3f); - } - - test_case.set_shader_filter({"nchw_to", "to_nchw", "view_copy"}); - - return test_case; -} - -TestCase create_conv2d_dw_test_case( - const Conv2dDwConfig& config, - vkapi::ScalarType dtype, - utils::StorageType storage_type, - utils::GPUMemoryLayout memory_layout, - const std::string& impl_selector) { - TestCase test_case; - - bool is_perf = config.dims.C > kRefDimSizeLimit || - config.dims.H > kRefDimSizeLimit || config.dims.W > kRefDimSizeLimit; - - std::string prefix = is_perf ? "PERF" : "ACCU"; - std::string storage_str = repr_str(storage_type, memory_layout); - std::string dtype_str = dtype_short(dtype); - std::string bias_str = config.has_bias ? "+bias" : ""; - - int64_t H_out = calc_out_size( - config.dims.H, - config.kernel.h, - config.stride.h, - config.padding.h, - config.dilation.h); - int64_t W_out = calc_out_size( - config.dims.W, - config.kernel.w, - config.stride.w, - config.padding.w, - config.dilation.w); - (void)H_out; - (void)W_out; - - // groups for depthwise conv2d == number of input channels - std::string shape = "[" + std::to_string(config.dims.N) + "," + - std::to_string(config.dims.C) + "," + std::to_string(config.dims.H) + - "," + std::to_string(config.dims.W) + "] k" + - std::to_string(config.kernel.h) + " s" + std::to_string(config.stride.h) + - " p" + std::to_string(config.padding.h) + " d" + - std::to_string(config.dilation.h) + " g" + std::to_string(config.dims.C); - - std::string suffix = bias_str; - if (!impl_selector.empty()) { - if (!suffix.empty()) { - suffix += " "; - } - suffix += "[" + impl_selector + "]"; - } - - std::string name = - make_test_label(prefix, dtype_str, dtype_str, shape, storage_str, suffix); - - test_case.set_name(name); - test_case.set_operator_name("test_etvk.test_conv2d_dw.default"); - - // Input tensor [N, C, H, W] - ValueSpec input( - {config.dims.N, config.dims.C, config.dims.H, config.dims.W}, - dtype, - storage_type, - memory_layout, - DataGenType::RANDOM); - - // Weight tensor [C, 1, K_h, K_w] - constant - ValueSpec weight( - {config.dims.C, 1, config.kernel.h, config.kernel.w}, - dtype, - storage_type, - memory_layout, - DataGenType::RANDOM); - weight.set_constant(true); - - test_case.add_input_spec(input); - test_case.add_input_spec(weight); - - // Bias (or none) - if (config.has_bias) { - ValueSpec bias( - {config.dims.C}, - dtype, - storage_type, - memory_layout, - DataGenType::RANDOM); - bias.set_constant(true); - test_case.add_input_spec(bias); - } else { - ValueSpec none_bias(static_cast(0)); - none_bias.set_none(true); - test_case.add_input_spec(none_bias); - } - - // stride_h, stride_w, padding_h, padding_w, dilation_h, dilation_w - test_case.add_input_spec(ValueSpec(static_cast(config.stride.h))); - test_case.add_input_spec(ValueSpec(static_cast(config.stride.w))); - test_case.add_input_spec(ValueSpec(static_cast(config.padding.h))); - test_case.add_input_spec(ValueSpec(static_cast(config.padding.w))); - test_case.add_input_spec(ValueSpec(static_cast(config.dilation.h))); - test_case.add_input_spec(ValueSpec(static_cast(config.dilation.w))); - - // impl_selector string - test_case.add_input_spec(ValueSpec::make_string(impl_selector)); - - // Output tensor [N, C, H_out, W_out] - ValueSpec output( - {config.dims.N, config.dims.C, H_out, W_out}, - dtype, - storage_type, - memory_layout, - DataGenType::ZEROS); - test_case.add_output_spec(output); - - if (dtype == vkapi::kHalf) { - test_case.set_abs_tolerance(1e-1f); - test_case.set_rel_tolerance(1e-2f); - } else { - test_case.set_abs_tolerance(1e-3f); - test_case.set_rel_tolerance(1e-3f); - } - - test_case.set_shader_filter({"nchw_to", "to_nchw", "view_copy"}); - - return test_case; -} - -TestCase create_conv2d_pw_test_case( - const Conv2dPwConfig& config, - vkapi::ScalarType dtype, - utils::StorageType storage_type, - utils::GPUMemoryLayout memory_layout) { - TestCase test_case; - - bool is_perf = config.C_in > kRefDimSizeLimit || - config.C_out > kRefDimSizeLimit || config.H > kRefDimSizeLimit || - config.W > kRefDimSizeLimit; - - std::string prefix = is_perf ? "PERF" : "ACCU"; - std::string storage_str = repr_str(storage_type, memory_layout); - std::string dtype_str = dtype_short(dtype); - std::string bias_str = config.has_bias ? "+bias" : ""; - - // Pointwise conv2d: kernel 1x1, stride 1, pad 0, dilation 1, groups 1 - std::string shape = "[" + std::to_string(config.N) + "," + - std::to_string(config.C_in) + "," + std::to_string(config.H) + "," + - std::to_string(config.W) + "]x[" + std::to_string(config.C_out) + "," + - std::to_string(config.C_in) + ",1,1] s1 p0 d1 g1"; - - std::string name = make_test_label( - prefix, dtype_str, dtype_str, shape, storage_str, bias_str); - - test_case.set_name(name); - test_case.set_operator_name("test_etvk.test_conv2d_pw.default"); - - // Input tensor [N, C_in, H, W] - ValueSpec input( - {config.N, config.C_in, config.H, config.W}, - dtype, - storage_type, - memory_layout, - DataGenType::RANDOM); - - // Weight tensor [C_out, C_in, 1, 1] - constant - ValueSpec weight( - {config.C_out, config.C_in, 1, 1}, - dtype, - storage_type, - memory_layout, - DataGenType::RANDOM); - weight.set_constant(true); - - test_case.add_input_spec(input); - test_case.add_input_spec(weight); - - // Bias (or none) - if (config.has_bias) { - ValueSpec bias( - {config.C_out}, - dtype, - storage_type, - memory_layout, - DataGenType::RANDOM); - bias.set_constant(true); - test_case.add_input_spec(bias); - } else { - ValueSpec none_bias(static_cast(0)); - none_bias.set_none(true); - test_case.add_input_spec(none_bias); - } - - // impl_selector - ValueSpec impl_selector_spec = ValueSpec::make_string("default"); - test_case.add_input_spec(impl_selector_spec); - - // Output tensor [N, C_out, H, W] - ValueSpec output( - {config.N, config.C_out, config.H, config.W}, - dtype, - storage_type, - memory_layout, - DataGenType::ZEROS); - test_case.add_output_spec(output); - - if (dtype == vkapi::kHalf) { - test_case.set_abs_tolerance(1e-1f); - test_case.set_rel_tolerance(1e-2f); - } else { - test_case.set_abs_tolerance(1e-3f); - test_case.set_rel_tolerance(1e-3f); - } - - test_case.set_shader_filter({"nchw_to", "to_nchw", "view_copy"}); - - return test_case; -} - -int64_t conv2d_flop_calculator(const TestCase& test_case) { - auto weight_sizes = test_case.inputs()[1].get_tensor_sizes(); - auto output_sizes = test_case.outputs()[0].get_tensor_sizes(); - - int64_t N = output_sizes[0]; - int64_t C_out = output_sizes[1]; - int64_t H_out = output_sizes[2]; - int64_t W_out = output_sizes[3]; - int64_t C_in = weight_sizes[1]; - int64_t K_h = weight_sizes[2]; - int64_t K_w = weight_sizes[3]; - - return 2 * N * C_out * C_in * H_out * W_out * K_h * K_w; -} - -int64_t conv2d_dw_flop_calculator(const TestCase& test_case) { - auto input_sizes = test_case.inputs()[0].get_tensor_sizes(); - auto weight_sizes = test_case.inputs()[1].get_tensor_sizes(); - auto output_sizes = test_case.outputs()[0].get_tensor_sizes(); - - int64_t N = output_sizes[0]; - int64_t C = output_sizes[1]; - int64_t H_out = output_sizes[2]; - int64_t W_out = output_sizes[3]; - int64_t K_h = weight_sizes[2]; - int64_t K_w = weight_sizes[3]; - - // Each output element: K_h * K_w multiplies + (K_h * K_w - 1) adds - return 2 * N * C * H_out * W_out * K_h * K_w; -} - -int64_t conv2d_pw_flop_calculator(const TestCase& test_case) { - auto input_sizes = test_case.inputs()[0].get_tensor_sizes(); - auto weight_sizes = test_case.inputs()[1].get_tensor_sizes(); - - int64_t N = input_sizes[0]; - int64_t C_in = input_sizes[1]; - int64_t H = input_sizes[2]; - int64_t W = input_sizes[3]; - int64_t C_out = weight_sizes[0]; - - return 2 * N * C_out * C_in * H * W; -} - -// Reference implementation for general conv2d. -// -// Supports both FP32 and (small-shape) FP16 inputs. The math is always done in -// float; for FP16 the master input/weight/bias values are dequantized from -// their half storage via get_element(), and the resulting float reference is -// compared against the dequantized GPU output by validate_against_reference(). -// -// FP16 accumulation error grows with K (= K_h * K_w * C_in). For large-K PERF -// shapes the FP32 reference would diverge from the GPU's FP16 accumulation -// enough to trip even the relaxed half tolerance, producing false failures, so -// those are intentionally left timing-only: this function throws -// std::invalid_argument, which execute_test_cases() catches to skip the -// correctness check (ref_computed stays false) while still benchmarking. -void conv2d_reference_impl(TestCase& test_case) { - const ValueSpec& input = test_case.inputs()[0]; - const ValueSpec& weight = test_case.inputs()[1]; - const ValueSpec& bias_spec = test_case.inputs()[2]; - ValueSpec& output = test_case.outputs()[0]; - - if (input.dtype != vkapi::kFloat && input.dtype != vkapi::kHalf) { - throw std::invalid_argument("Reference only supports float and half"); - } - - auto input_sizes = input.get_tensor_sizes(); - auto weight_sizes = weight.get_tensor_sizes(); - auto output_sizes = output.get_tensor_sizes(); - - int64_t N = input_sizes[0]; - int64_t C_in = input_sizes[1]; - int64_t H_in = input_sizes[2]; - int64_t W_in = input_sizes[3]; - int64_t C_out = weight_sizes[0]; - int64_t K_h = weight_sizes[2]; - int64_t K_w = weight_sizes[3]; - int64_t H_out = output_sizes[2]; - int64_t W_out = output_sizes[3]; - - // For FP16, only compute a reference for small (ACCU) shapes where K is small - // enough that FP32-vs-FP16 accumulation error stays within the half - // tolerance. Large-K PERF half shapes stay timing-only via the throw below. - // The predicate mirrors create_conv2d_test_case's is_perf classification. - if (input.dtype == vkapi::kHalf) { - const bool is_perf = conv2d_is_perf_shape(C_in, C_out, H_in, W_in); - if (is_perf) { - throw std::invalid_argument( - "Half reference skipped for large-K PERF shape (timing-only)"); - } - } - - int64_t stride_h = test_case.inputs()[3].get_int_value(); - int64_t stride_w = test_case.inputs()[4].get_int_value(); - int64_t padding_h = test_case.inputs()[5].get_int_value(); - int64_t padding_w = test_case.inputs()[6].get_int_value(); - int64_t dilation_h = test_case.inputs()[7].get_int_value(); - int64_t dilation_w = test_case.inputs()[8].get_int_value(); - const int64_t groups = test_case.inputs()[10].get_int_value(); - const int64_t in_group_channels = C_in / groups; - const int64_t out_group_channels = C_out / groups; - const bool clamp_out = test_case.inputs()[11].get_bool_value(); - const float out_min = test_case.inputs()[12].get_float_value(); - const float out_max = test_case.inputs()[13].get_float_value(); - - // get_element() materializes a float regardless of dtype (it dequantizes - // half master data), so the same loop body serves both FP32 and FP16. - auto& ref_data = output.get_ref_float_data(); - ref_data.resize(N * C_out * H_out * W_out, 0.0f); - - for (int64_t n = 0; n < N; ++n) { - for (int64_t co = 0; co < C_out; ++co) { - for (int64_t oh = 0; oh < H_out; ++oh) { - for (int64_t ow = 0; ow < W_out; ++ow) { - float sum = 0.0f; - for (int64_t ci = 0; ci < in_group_channels; ++ci) { - for (int64_t kh = 0; kh < K_h; ++kh) { - for (int64_t kw = 0; kw < K_w; ++kw) { - int64_t ih = oh * stride_h - padding_h + kh * dilation_h; - int64_t iw = ow * stride_w - padding_w + kw * dilation_w; - if (ih >= 0 && ih < H_in && iw >= 0 && iw < W_in) { - const int64_t input_channel = - co / out_group_channels * in_group_channels + ci; - float in_val = input.get_element( - n * (C_in * H_in * W_in) + input_channel * (H_in * W_in) + - ih * W_in + iw); - // weight is [C_out, C_in / groups, K_h, K_w] - float w_val = weight.get_element( - co * (in_group_channels * K_h * K_w) + ci * (K_h * K_w) + - kh * K_w + kw); - sum += in_val * w_val; - } - } - } - } - if (!bias_spec.is_none()) { - sum += bias_spec.get_element(co); - } - if (clamp_out) { - sum = std::clamp(sum, out_min, out_max); - } - ref_data - [n * (C_out * H_out * W_out) + co * (H_out * W_out) + oh * W_out + - ow] = sum; - } - } - } - } -} - -void conv2d_exact_operands_reference(TestCase& test_case) { - auto reference = test_case; - for (size_t i = 0; i < 3; ++i) { - if (test_case.inputs()[i].is_none()) { - continue; - } - reference.inputs()[i].dtype = vkapi::kFloat; - auto& values = reference.inputs()[i].get_float_data(); - values.resize(test_case.inputs()[i].numel()); - for (size_t j = 0; j < values.size(); ++j) { - values[j] = test_case.inputs()[i].get_element(j); - } - } - conv2d_reference_impl(reference); - test_case.outputs()[0].share_reference_from(reference.outputs()[0]); -} - -// Reference implementation for depthwise conv2d -void conv2d_dw_reference_impl(TestCase& test_case) { - const ValueSpec& input = test_case.inputs()[0]; - const ValueSpec& weight = test_case.inputs()[1]; - const ValueSpec& bias_spec = test_case.inputs()[2]; - ValueSpec& output = test_case.outputs()[0]; - - if (input.dtype != vkapi::kFloat) { - throw std::invalid_argument("Reference only supports float"); - } - - auto input_sizes = input.get_tensor_sizes(); - auto weight_sizes = weight.get_tensor_sizes(); - auto output_sizes = output.get_tensor_sizes(); - - int64_t N = input_sizes[0]; - int64_t C = input_sizes[1]; - int64_t H_in = input_sizes[2]; - int64_t W_in = input_sizes[3]; - int64_t K_h = weight_sizes[2]; - int64_t K_w = weight_sizes[3]; - int64_t H_out = output_sizes[2]; - int64_t W_out = output_sizes[3]; - - int64_t stride_h = test_case.inputs()[3].get_int_value(); - int64_t stride_w = test_case.inputs()[4].get_int_value(); - int64_t padding_h = test_case.inputs()[5].get_int_value(); - int64_t padding_w = test_case.inputs()[6].get_int_value(); - int64_t dilation_h = test_case.inputs()[7].get_int_value(); - int64_t dilation_w = test_case.inputs()[8].get_int_value(); - - auto& input_data = input.get_float_data(); - auto& weight_data = weight.get_float_data(); - auto& ref_data = output.get_ref_float_data(); - ref_data.resize(N * C * H_out * W_out, 0.0f); - - for (int64_t n = 0; n < N; ++n) { - for (int64_t c = 0; c < C; ++c) { - for (int64_t oh = 0; oh < H_out; ++oh) { - for (int64_t ow = 0; ow < W_out; ++ow) { - float sum = 0.0f; - for (int64_t kh = 0; kh < K_h; ++kh) { - for (int64_t kw = 0; kw < K_w; ++kw) { - int64_t ih = oh * stride_h - padding_h + kh * dilation_h; - int64_t iw = ow * stride_w - padding_w + kw * dilation_w; - if (ih >= 0 && ih < H_in && iw >= 0 && iw < W_in) { - float in_val = input_data - [n * (C * H_in * W_in) + c * (H_in * W_in) + ih * W_in + - iw]; - // weight is [C, 1, K_h, K_w] - float w_val = weight_data[c * (K_h * K_w) + kh * K_w + kw]; - sum += in_val * w_val; - } - } - } - if (!bias_spec.is_none()) { - auto& bias_data = bias_spec.get_float_data(); - sum += bias_data[c]; - } - ref_data - [n * (C * H_out * W_out) + c * (H_out * W_out) + oh * W_out + - ow] = sum; - } - } - } - } -} - -// Reference implementation: pointwise conv2d is essentially a matmul -// output[n][c_out][h][w] = bias[c_out] + -// sum_over_c_in(input[n][c_in][h][w] * weight[c_out][c_in][0][0]) -void conv2d_pw_reference_impl(TestCase& test_case) { - // input[0], weight[1], bias[2], impl_selector[3] - const ValueSpec& input = test_case.inputs()[0]; - const ValueSpec& weight = test_case.inputs()[1]; - const ValueSpec& bias_spec = test_case.inputs()[2]; - ValueSpec& output = test_case.outputs()[0]; - - if (input.dtype != vkapi::kFloat && input.dtype != vkapi::kHalf) { - throw std::invalid_argument("Reference only supports float and half"); - } - - auto input_sizes = input.get_tensor_sizes(); - auto weight_sizes = weight.get_tensor_sizes(); - - int64_t N = input_sizes[0]; - int64_t C_in = input_sizes[1]; - int64_t H = input_sizes[2]; - int64_t W = input_sizes[3]; - int64_t C_out = weight_sizes[0]; - - // Mirrors create_conv2d_pw_test_case's PERF classification: large-K half - // shapes stay timing-only, since FP16 accumulation error would exceed the - // half tolerance. - if (input.dtype == vkapi::kHalf && - (C_in > kRefDimSizeLimit || C_out > kRefDimSizeLimit || - H > kRefDimSizeLimit || W > kRefDimSizeLimit)) { - throw std::invalid_argument( - "Half reference skipped for PERF shape (timing-only)"); - } - - // get_element() materializes a float regardless of dtype, so the same loop - // body serves both FP32 and FP16. - auto& ref_data = output.get_ref_float_data(); - ref_data.resize(N * C_out * H * W, 0.0f); - - for (int64_t n = 0; n < N; ++n) { - for (int64_t co = 0; co < C_out; ++co) { - for (int64_t h = 0; h < H; ++h) { - for (int64_t w = 0; w < W; ++w) { - float sum = 0.0f; - for (int64_t ci = 0; ci < C_in; ++ci) { - float in_val = input.get_element( - n * (C_in * H * W) + ci * (H * W) + h * W + w); - // weight is [C_out, C_in, 1, 1] - float w_val = weight.get_element(co * C_in + ci); - sum += in_val * w_val; - } - if (!bias_spec.is_none()) { - sum += bias_spec.get_element(co); - } - ref_data[n * (C_out * H * W) + co * (H * W) + h * W + w] = sum; - } - } - } - } -} - -} // namespace conv2d -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/conv2d/conv2d.h b/backends/vulkan/test/ops/benchmark/cases/conv2d/conv2d.h deleted file mode 100644 index 73219e5a5c9..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/conv2d/conv2d.h +++ /dev/null @@ -1,110 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#pragma once - -#include -#include -#include - -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace conv2d { - -constexpr int64_t kRefDimSizeLimit = 64; - -// Conv2d shaders are texture-only and require channels-packed layout -constexpr utils::StorageType kStorageType = utils::kTexture3D; -constexpr utils::GPUMemoryLayout kMemoryLayout = utils::kChannelsPacked; - -struct InputDims { - int64_t N; - int64_t C; - int64_t H; - int64_t W; - - InputDims(int64_t n, int64_t c, int64_t h, int64_t w) - : N(n), C(c), H(h), W(w) {} -}; - -struct Conv2dTestConfig { - InputDims dims; - int64_t C_out; - KernelSize kernel; - Stride stride; - Padding padding; - Dilation dilation; - bool has_bias; - int64_t groups = 1; - struct Clamp { - float min; - float max; - }; - std::optional clamp = std::nullopt; -}; - -struct Conv2dDwConfig { - InputDims dims; - KernelSize kernel; - Stride stride; - Padding padding; - Dilation dilation; - bool has_bias; -}; - -struct Conv2dPwConfig { - int64_t N; - int64_t C_in; - int64_t C_out; - int64_t H; - int64_t W; - bool has_bias; -}; - -bool conv2d_is_perf_shape(int64_t C_in, int64_t C_out, int64_t H, int64_t W); - -TestCase create_conv2d_test_case( - const Conv2dTestConfig& config, - vkapi::ScalarType dtype, - utils::StorageType storage_type, - utils::GPUMemoryLayout memory_layout, - const std::string& impl_selector = ""); - -TestCase create_conv2d_dw_test_case( - const Conv2dDwConfig& config, - vkapi::ScalarType dtype, - utils::StorageType storage_type, - utils::GPUMemoryLayout memory_layout, - const std::string& impl_selector = ""); - -TestCase create_conv2d_pw_test_case( - const Conv2dPwConfig& config, - vkapi::ScalarType dtype, - utils::StorageType storage_type, - utils::GPUMemoryLayout memory_layout); - -void conv2d_reference_impl(TestCase& test_case); - -void conv2d_exact_operands_reference(TestCase& test_case); - -void conv2d_dw_reference_impl(TestCase& test_case); - -void conv2d_pw_reference_impl(TestCase& test_case); - -int64_t conv2d_flop_calculator(const TestCase& test_case); - -int64_t conv2d_dw_flop_calculator(const TestCase& test_case); - -int64_t conv2d_pw_flop_calculator(const TestCase& test_case); - -} // namespace conv2d -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/conv2d/test_sets/conv2d_sets.cpp b/backends/vulkan/test/ops/benchmark/cases/conv2d/test_sets/conv2d_sets.cpp deleted file mode 100644 index 19eba08ae69..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/conv2d/test_sets/conv2d_sets.cpp +++ /dev/null @@ -1,342 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace conv2d { - -namespace { - -struct Conv2dCaseGroup { - std::vector configs; - std::vector dtypes; - std::vector impls; -}; - -std::vector generate_test_cases( - const std::vector& groups) { - std::vector test_cases; - - for (const auto& group : groups) { - for (const auto& config : group.configs) { - for (auto dtype : group.dtypes) { - for (const auto& impl : group.impls) { - test_cases.push_back(create_conv2d_test_case( - config, dtype, kStorageType, kMemoryLayout, impl)); - } - } - } - } - - return test_cases; -} - -} // namespace - -REGISTER_TEST_CASE_SET("conv2d", "correctness") { - // Accuracy shapes (small enough for float reference validation) - std::vector accuracy_configs = { - // 3x3 stride=1 pad=1 same-channels (the bottleneck pattern in TinyCNN) - {InputDims(1, 8, 8, 8), - 8, - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - false}, - {InputDims(1, 8, 8, 8), - 8, - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - true}, - {InputDims(1, 16, 16, 16), - 16, - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - true}, - // 3x3 stride=2 (downsample) with channel expansion - {InputDims(1, 8, 16, 16), - 16, - KernelSize(3, 3), - Stride(2, 2), - Padding(1, 1), - Dilation(1, 1), - false}, - // 3x3 stride=1 with channel reduction - {InputDims(1, 16, 8, 8), - 8, - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - false}, - // Non-multiple-of-4 channels - {InputDims(1, 11, 8, 8), - 13, - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - false}, - // 3-channel input (like RGB stem) - {InputDims(1, 3, 16, 16), - 8, - KernelSize(3, 3), - Stride(2, 2), - Padding(1, 1), - Dilation(1, 1), - false}, - }; - - // Small shapes used to exercise each im2col intermediate-storage variant - // (buffer / texture2d / texture3d) deterministically and independently of - // the device's auto-selection. All dims < kRefDimSizeLimit so both the FP32 - // and FP16 references validate them. The first two shapes keep M (= H_out * - // W_out) tiny, so the im2col intermediate [1, K_total, H_out, W_out] is small - // and the forced texture3d variant never exercises the large-M texture3d - // Z-layout the auto-selector actually falls back to. The third shape (48x48) - // is included specifically so the forced im2col_tex3d variant exercises the - // texture3d layout (K4 along Z, many spatial rows) at a non-trivial M, while - // still keeping every dim under kRefDimSizeLimit for reference validation. - std::vector per_variant_configs = { - // 3x3 s1 p1, channels multiple of 4 - {InputDims(1, 16, 16, 16), - 16, - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - true}, - // Non-multiple-of-4 channels exercise the Cin padding path - {InputDims(1, 11, 12, 12), - 13, - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - false}, - // Larger spatial extent (M = 48*48 = 2304) exercises the texture3d im2col - // layout [1, K_total, H_out, W_out] with K4 along Z at a non-trivial M, - // while all dims stay < kRefDimSizeLimit so both FP32 and FP16 references - // validate it. C_in=16 keeps K = 3*3*16 = 144 (same as the 16x16 case) - // so FP16 accumulation stays within tolerance. - {InputDims(1, 16, 48, 48), - 16, - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - true}, - }; - - // Implementation variants exercised for every small ACCU shape: - // "" -> heuristic-routed (should_use_conv2d_im2col picks direct on - // Adreno for small c_out, im2col on Mali) - // "im2col" -> forced im2col/GEMM path - // "direct" -> forced direct sliding-window path (conv2d_direct_impl) - // Including "direct" guarantees the direct shader gets reference-checked on - // BOTH devices — without it, Mali would always route "" to im2col and never - // exercise the direct path. - const std::vector impls = {"", "im2col", "direct"}; - // Forced-storage im2col variants for the per-variant ACCU coverage. - const std::vector forced_storage_impls = { - "im2col_buffer", "im2col_tex2d", "im2col_tex3d"}; - - // FP16 small shapes get a real reference check (gated in - // conv2d_reference_impl); we run both dtypes so we catch correctness - // regressions in either path. Large-K half stays timing-only via the - // reference's PERF-shape throw. - const std::vector accu_dtypes = { - vkapi::kFloat, vkapi::kHalf}; - - const std::vector groups = { - // Accuracy test cases for all impls and both dtypes. - {accuracy_configs, accu_dtypes, impls}, - // Per-variant forced-storage ACCU cases (FP32 and FP16) so all three - // im2col intermediate-storage variants get deterministic, - // device-independent, reference-checked coverage at small K. - {per_variant_configs, accu_dtypes, forced_storage_impls}, - }; - return { - generate_test_cases(groups), - conv2d_reference_impl, - conv2d_flop_calculator, - /*warmup_runs = */ 5, - /*benchmark_runs = */ 20}; -} - -REGISTER_TEST_CASE_SET("conv2d", "performance") { - // TinyCNN depth estimator hotspots (from profiling - // UNTRAINED_TinyCNNDepthEstimatorRealTime_Vulkan.pte). - // Each entry lists (C_in, H, W) -> C_out, all 3x3 stride=1 pad=1 unless - // noted. Together the first 6 entries account for ~89% of all conv time. - std::vector perf_configs = { - // #1: 21.25% — (1,128,36,48)->(1,128,36,48) - {InputDims(1, 128, 36, 48), - 128, - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - true}, - // #2: 20.68% — (1,256,18,24)->(1,256,18,24) - {InputDims(1, 256, 18, 24), - 256, - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - true}, - // #3: 20.01% — (1,64,72,96)->(1,64,72,96) - {InputDims(1, 64, 72, 96), - 64, - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - true}, - // #4: 13.25% — (1,32,144,192)->(1,32,144,192) - {InputDims(1, 32, 144, 192), - 32, - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - true}, - // #5: 6.74% — (1,64,36,48)->(1,64,36,48) - {InputDims(1, 64, 36, 48), - 64, - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - true}, - // #6: 5.90% — (1,32,72,96)->(1,32,72,96) - {InputDims(1, 32, 72, 96), - 32, - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - true}, - // Secondary cases - // 3x3 stride=2 downsample with channel expansion: 1.52% - {InputDims(1, 32, 72, 96), - 128, - KernelSize(3, 3), - Stride(2, 2), - Padding(1, 1), - Dilation(1, 1), - false}, - // 3x3 stride=1 same-shape, smaller spatial: 1.51% - {InputDims(1, 128, 18, 24), - 128, - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - true}, - // 3x3 stride=1, channel reduction - {InputDims(1, 128, 18, 24), - 64, - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - false}, - {InputDims(1, 64, 36, 48), - 32, - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - false}, - // 3x3 stride=2 downsample, same channels - {InputDims(1, 32, 72, 96), - 32, - KernelSize(3, 3), - Stride(2, 2), - Padding(1, 1), - Dilation(1, 1), - false}, - {InputDims(1, 64, 36, 48), - 64, - KernelSize(3, 3), - Stride(2, 2), - Padding(1, 1), - Dilation(1, 1), - false}, - // RGB stem - {InputDims(1, 3, 144, 192), - 32, - KernelSize(3, 3), - Stride(2, 2), - Padding(1, 1), - Dilation(1, 1), - false}, - }; - - // Boundary pair straddling the should_use_conv2d_im2col() c_out >= 128 - // routing threshold. Spatial dims are tiny (8x8) so the FP32 float reference - // stays cheap, but c_out = 64 / 128 are both >= kRefDimSizeLimit, so these - // get the PERF label. FP32 PERF cases are still numerically VERIFIED (the - // reference's invalid_argument throw that skips the check only fires for - // half), so both implementations are cross-checked against the float - // reference at the boundary. Run all three impls: at c_out = 64 the heuristic - // ("") picks direct on Adreno / im2col on Mali; at c_out = 128 it picks - // im2col on both — and "direct"/"im2col" force each path regardless, proving - // the two implementations agree at the boundary on either device. - std::vector boundary_configs = { - // c_out = 64 (< 128): below the threshold - {InputDims(1, 16, 8, 8), - 64, - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - false}, - // c_out = 128 (== 128): at/above the threshold - {InputDims(1, 16, 8, 8), - 128, - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - false}, - }; - - const std::vector impls = {"", "im2col", "direct"}; - - const std::vector groups = { - // The c_out boundary pair (FP32 only) through all three impls. FP32 PERF - // cases are reference-VERIFIED, so the direct and im2col paths are both - // cross-checked against the float reference at the routing threshold. - {boundary_configs, {vkapi::kFloat}, impls}, - // Performance test cases (float and half) for all impls. - {perf_configs, {vkapi::kFloat, vkapi::kHalf}, impls}, - }; - return { - generate_test_cases(groups), - conv2d_reference_impl, - conv2d_flop_calculator, - /*warmup_runs = */ 5, - /*benchmark_runs = */ 20}; -} - -} // namespace conv2d -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/conv2d/test_sets/direct_sets.cpp b/backends/vulkan/test/ops/benchmark/cases/conv2d/test_sets/direct_sets.cpp deleted file mode 100644 index 8fe21f01edf..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/conv2d/test_sets/direct_sets.cpp +++ /dev/null @@ -1,212 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include -#include -#include -#include -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace conv2d { - -namespace { - -struct DirectConfig { - InputDims dims; - int64_t C_out; - std::array perf_hw; - int64_t groups = 1; - KernelSize kernel{3, 3}; - Stride stride{1, 1}; - Padding padding{1, 1}; - Dilation dilation{1, 1}; - bool has_bias = false; -}; - -// Quarter-valued Half products keep these feature tests independent of long -// FP16 reduction error. Group tests encode the input channel in each value. -void set_accuracy_operands(TestCase& test, int seed, int64_t groups) { - for (int i = 0; i < 2; ++i) { - auto& spec = test.inputs()[i]; - spec.ensure_data_generated(seed + i); - if (groups == 1 && spec.dtype != vkapi::kHalf) { - continue; - } - const auto value_at = [&](size_t j, float value) { - if (groups != 1) { - return i == 0 ? float(j / (spec.sizes[2] * spec.sizes[3]) + 1) - : 0.0625f; - } - return value >= 0.0f ? 0.5f : -0.5f; - }; - if (spec.dtype == vkapi::kHalf) { - auto& values = spec.get_half_data(); - for (size_t j = 0; j < values.size(); ++j) { - values[j] = float_to_half(value_at(j, half_to_float(values[j]))); - } - } else { - auto& values = spec.get_float_data(); - for (size_t j = 0; j < values.size(); ++j) { - values[j] = value_at(j, values[j]); - } - } - } -} - -std::vector direct_test_cases(bool performance) { - const std::vector configs = { - {InputDims(1, 16, 9, 13), 16, {48, 64}}, - {InputDims(1, 3, 15, 19), 8, {65, 97}, 1, KernelSize(3, 3), Stride(2, 2)}, - {InputDims(1, 11, 9, 13), 13, {48, 64}}, - {InputDims(1, 17, 9, 13), - 17, - {49, 65}, - 1, - KernelSize(5, 5), - Stride(1, 1), - Padding(2, 2)}, - {InputDims(1, 16, 9, 13), 32, {48, 64}}, - {InputDims(1, 16, 11, 17), - 24, - {31, 47}, - 1, - KernelSize(3, 5), - Stride(2, 1), - Padding(0, 3)}, - {InputDims(1, 16, 11, 17), - 16, - {48, 64}, - 1, - KernelSize(3, 3), - Stride(1, 1), - Padding(2, 3), - Dilation(2, 3)}, - {InputDims(1, 8, 11, 17), - 8, - {32, 48}, - 1, - KernelSize(7, 7), - Stride(1, 1), - Padding(3, 3)}, - {InputDims(1, 16, 1, 29), - 32, - {1, 1537}, - 1, - KernelSize(1, 5), - Stride(1, 1), - Padding(0, 2)}, - {InputDims(1, 8, 1, 1), - 8, - {0, 0}, - 1, - KernelSize(3, 3), - Stride(1, 1), - Padding(3, 4), - Dilation(1, 1), - true}, - {InputDims(1, 64, 8, 12), 64, {32, 48}}, - {InputDims(1, 64, 8, 12), - 64, - {32, 48}, - 1, - KernelSize(5, 5), - Stride(1, 1), - Padding(2, 2)}, - {InputDims(1, 128, 8, 12), - 128, - {16, 24}, - 1, - KernelSize(5, 5), - Stride(1, 1), - Padding(2, 2)}, - {InputDims(1, 16, 9, 13), 16, {48, 64}, 2}, - {InputDims(1, 16, 9, 13), 24, {48, 64}, 2}, - {InputDims(1, 6, 9, 13), 16, {0, 0}, 2}, - {InputDims(1, 16, 9, 13), 10, {0, 0}, 2}, - {InputDims(1, 6, 9, 13), 10, {0, 0}, 2}, - {InputDims(1, 4, 9, 13), 8, {0, 0}, 4}, - {InputDims(1, 3, 3, 3), - 1, - {3, 3}, - 1, - KernelSize(3, 3), - Stride(1, 1), - Padding(0, 0)}, - {InputDims(1, 6, 9, 13), 8, {0, 0}, 2}, - {InputDims(1, 6, 9, 13), 24, {0, 0}, 2}, - }; - - std::vector tests; - for (int i = 0; i < static_cast(configs.size()); ++i) { - const auto& config = configs[i]; - if (performance && config.perf_hw[0] == 0) { - continue; - } - const int features = i == 4 && !performance ? 4 : 1; - for (int feature = 0; feature < features; ++feature) { - Conv2dTestConfig conv{ - performance - ? InputDims( - 1, config.dims.C, config.perf_hw[0], config.perf_hw[1]) - : config.dims, - config.C_out, - config.kernel, - config.stride, - config.padding, - config.dilation, - config.has_bias, - config.groups}; - if (i == 4) { - conv.has_bias = performance || (feature & 1); - if (performance || (feature & 2)) { - conv.clamp = Conv2dTestConfig::Clamp{-0.25f, 0.5f}; - } - } - for (auto dtype : {vkapi::kFloat, vkapi::kHalf}) { - const std::vector selectors = performance - ? std::vector{"direct"} - : std::vector{"direct", "direct_masked"}; - for (const char* impl : selectors) { - auto test = create_conv2d_test_case( - conv, dtype, kStorageType, kMemoryLayout, impl); - test.set_name("M" + std::to_string(i + 1) + " " + test.name()); - if (!performance) { - set_accuracy_operands(test, 37 + i * 3, config.groups); - } - test.set_op_invocations_per_execute(performance ? 0 : 1); - tests.push_back(std::move(test)); - } - } - } - } - return tests; -} - -} // namespace - -REGISTER_TEST_CASE_SET("conv2d", "direct_correctness") { - return { - direct_test_cases(false), - conv2d_exact_operands_reference, - conv2d_flop_calculator, - 1, - 1}; -} - -REGISTER_TEST_CASE_SET("conv2d", "direct_performance") { - return {direct_test_cases(true), nullptr, conv2d_flop_calculator, 5, 20}; -} - -} // namespace conv2d -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/conv2d/test_sets/dsr_convs_sets.cpp b/backends/vulkan/test/ops/benchmark/cases/conv2d/test_sets/dsr_convs_sets.cpp deleted file mode 100644 index 737aafd6695..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/conv2d/test_sets/dsr_convs_sets.cpp +++ /dev/null @@ -1,170 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include -#include -#include -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace conv2d { - -namespace { - -std::vector dsr_conv_configs() { - const auto config = [](InputDims dims, int64_t c_out, int32_t kernel) { - return Conv2dTestConfig{ - dims, - c_out, - KernelSize(kernel, kernel), - Stride(1, 1), - Padding(kernel / 2, kernel / 2), - Dilation(1, 1), - false}; - }; - // DSR dim=64, config=[4,4,4,4,4], input 1512x2016 shapes. - std::vector configs = { - config(InputDims(1, 17, 756, 1008), 64, 3), - config(InputDims(1, 32, 756, 1008), 32, 3), - config(InputDims(1, 64, 378, 504), 64, 3), - config(InputDims(1, 128, 189, 252), 128, 3), - config(InputDims(1, 64, 756, 1008), 64, 3), - config(InputDims(1, 32, 756, 1008), 32, 5), - config(InputDims(1, 64, 378, 504), 64, 5), - config(InputDims(1, 128, 189, 252), 128, 5)}; - const size_t count = configs.size(); - for (size_t i = 0; i < count; ++i) { - auto biased = configs[i]; - biased.has_bias = true; - biased.clamp = - Conv2dTestConfig::Clamp{0.0f, std::numeric_limits::infinity()}; - configs.push_back(biased); - } - return configs; -} - -void set_half_accuracy_operands(TestCase& test, bool has_bias) { - for (int i = 0; i < (has_bias ? 3 : 2); ++i) { - auto& spec = test.inputs()[i]; - spec.ensure_data_generated(42 + i); - for (auto& value : spec.get_half_data()) { - value = float_to_half(half_to_float(value) >= 0.0f ? 0.25f : -0.25f); - } - } -} - -} // namespace - -REGISTER_TEST_CASE_SET("conv2d", "dsr_convs_set") { - std::vector test_cases; - for (const auto& config : dsr_conv_configs()) { - test_cases.push_back(create_conv2d_test_case( - config, vkapi::kHalf, kStorageType, kMemoryLayout)); - } - // Full-resolution FP16 configurations are timing-only. - return {test_cases, nullptr, conv2d_flop_calculator, 5, 20}; -} - -REGISTER_TEST_CASE_SET("conv2d", "dsr_convs_comparison") { - std::vector test_cases; - for (const auto& config : dsr_conv_configs()) { - const std::string baseline = config.C_out >= 128 ? "im2col" : "direct"; - for (const bool optimized : {false, true}) { - auto test_case = create_conv2d_test_case( - config, - vkapi::kHalf, - kStorageType, - kMemoryLayout, - optimized ? "" : baseline); - test_case.set_name( - test_case.name() + (optimized ? " [optimized]" : " [baseline]")); - test_cases.push_back(std::move(test_case)); - } - } - return {test_cases, nullptr, conv2d_flop_calculator, 5, 20}; -} - -REGISTER_TEST_CASE_SET("conv2d", "dsr_convs_correctness") { - std::vector test_cases; - std::unordered_set names; - for (auto config : dsr_conv_configs()) { - config.dims.H = 8; - config.dims.W = 12; - for (auto dtype : {vkapi::kFloat, vkapi::kHalf}) { - auto accuracy_config = config; - // FP16 references require dimensions below kRefDimSizeLimit. - if (dtype == vkapi::kHalf) { - if (accuracy_config.dims.C == 17) { - accuracy_config.C_out = 31; - } - if (accuracy_config.dims.C >= kRefDimSizeLimit || - accuracy_config.C_out >= kRefDimSizeLimit) { - continue; - } - } - auto test_case = create_conv2d_test_case( - accuracy_config, dtype, kStorageType, kMemoryLayout); - if (dtype == vkapi::kHalf && accuracy_config.has_bias) { - set_half_accuracy_operands(test_case, true); - } - if (dtype == vkapi::kHalf && accuracy_config.dims.C == 32 && - accuracy_config.C_out == 32 && accuracy_config.kernel.h == 5 && - accuracy_config.kernel.w == 5) { - // The 800-term FP16 GEMM sum rounds each FMA; the reference uses FP32. - test_case.set_abs_tolerance(0.2f); - test_case.set_rel_tolerance(0.02f); - } - if (names.insert(test_case.name()).second) { - test_cases.push_back(test_case); - } - } - } - return {test_cases, conv2d_reference_impl, conv2d_flop_calculator, 1, 1}; -} - -REGISTER_TEST_CASE_SET("conv2d", "dsr_bias_clamp_correctness") { - std::vector test_cases; - size_t index = 0; - for (auto config : dsr_conv_configs()) { - if (config.has_bias) { - continue; - } - ++index; - config.dims.H = 9; - config.dims.W = 13; - for (int feature = 1; feature < 4; ++feature) { - config.has_bias = feature & 1; - config.clamp = feature & 2 - ? std::optional(Conv2dTestConfig::Clamp{ - 0.0f, - config.has_bias ? std::numeric_limits::infinity() - : 6.0f}) - : std::nullopt; - auto test = create_conv2d_test_case( - config, vkapi::kHalf, kStorageType, kMemoryLayout, "direct"); - test.set_name("C" + std::to_string(index) + " " + test.name()); - set_half_accuracy_operands(test, config.has_bias); - test.set_op_invocations_per_execute(1); - test_cases.push_back(std::move(test)); - } - } - return { - test_cases, - conv2d_exact_operands_reference, - conv2d_flop_calculator, - 1, - 1}; -} - -} // namespace conv2d -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/conv2d/test_sets/dw_sets.cpp b/backends/vulkan/test/ops/benchmark/cases/conv2d/test_sets/dw_sets.cpp deleted file mode 100644 index 51c9cf1f54b..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/conv2d/test_sets/dw_sets.cpp +++ /dev/null @@ -1,218 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace conv2d { - -namespace { - -std::vector generate_dw_test_cases( - const std::vector& configs, - const std::vector& dtypes, - const std::vector& impls) { - std::vector test_cases; - - for (const auto& config : configs) { - for (auto dtype : dtypes) { - for (const auto& impl : impls) { - test_cases.push_back(create_conv2d_dw_test_case( - config, dtype, kStorageType, kMemoryLayout, impl)); - } - } - } - - return test_cases; -} - -} // namespace - -REGISTER_TEST_CASE_SET("conv2d", "dw_correctness") { - // Accuracy shapes (small enough for float reference validation) - std::vector accuracy_configs = { - {InputDims(1, 8, 16, 16), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - false}, - {InputDims(1, 8, 16, 16), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - true}, - {InputDims(1, 8, 16, 16), - KernelSize(5, 5), - Stride(1, 1), - Padding(2, 2), - Dilation(1, 1), - false}, - {InputDims(1, 8, 16, 16), - KernelSize(3, 3), - Stride(2, 2), - Padding(1, 1), - Dilation(1, 1), - false}, - // Neither 3x3 nor 5x5 with stride != dilation: the generic conv2d_dw - // shader, which has no _sned variant - {InputDims(1, 8, 16, 16), - KernelSize(4, 4), - Stride(2, 2), - Padding(1, 1), - Dilation(1, 1), - false}, - // dilation > 1 on the generic shader, with stride != dilation and - // with stride == dilation - {InputDims(1, 8, 32, 32), - KernelSize(7, 7), - Stride(1, 1), - Padding(6, 6), - Dilation(2, 2), - true}, - {InputDims(1, 8, 32, 32), - KernelSize(6, 6), - Stride(3, 3), - Padding(2, 2), - Dilation(3, 3), - false}, - // Non-square kernel, stride, padding and input (catches x/y swaps) - {InputDims(1, 8, 31, 37), - KernelSize(3, 5), - Stride(2, 1), - Padding(1, 2), - Dilation(1, 1), - true}, - // Non-multiple-of-4 channels - {InputDims(1, 11, 16, 16), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - false}, - {InputDims(1, 3, 16, 16), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - false}, - }; - - // Accuracy test cases (float only, auto-selected implementation) - return { - generate_dw_test_cases(accuracy_configs, {vkapi::kFloat}, {""}), - conv2d_dw_reference_impl, - conv2d_dw_flop_calculator}; -} - -REGISTER_TEST_CASE_SET("conv2d", "dw_performance") { - // EdgeTAM depthwise shapes (from profiling data) - std::vector perf_configs = { - // Backbone stem and early stages - {InputDims(1, 24, 512, 512), - KernelSize(3, 3), - Stride(2, 2), - Padding(1, 1), - Dilation(1, 1), - false}, - {InputDims(1, 48, 256, 256), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - false}, - {InputDims(1, 48, 256, 256), - KernelSize(3, 3), - Stride(2, 2), - Padding(1, 1), - Dilation(1, 1), - false}, - {InputDims(1, 96, 128, 128), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - false}, - {InputDims(1, 96, 128, 128), - KernelSize(3, 3), - Stride(2, 2), - Padding(1, 1), - Dilation(1, 1), - false}, - {InputDims(1, 192, 64, 64), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - false}, - {InputDims(1, 192, 64, 64), - KernelSize(3, 3), - Stride(2, 2), - Padding(1, 1), - Dilation(1, 1), - false}, - {InputDims(1, 384, 32, 32), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - false}, - // 5x5 kernels - {InputDims(1, 48, 256, 256), - KernelSize(5, 5), - Stride(1, 1), - Padding(2, 2), - Dilation(1, 1), - false}, - {InputDims(1, 96, 128, 128), - KernelSize(5, 5), - Stride(1, 1), - Padding(2, 2), - Dilation(1, 1), - false}, - // FPN/Neck - {InputDims(1, 256, 256, 256), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - false}, - {InputDims(1, 256, 128, 128), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - false}, - }; - - const std::vector impls = { - // Auto-selection (empty impl_selector) - "", - // Force b4x2 variant - "b4x2", - // Force b1x1 variant (only for 3x3 kernels; for 5x5 it falls back - // to default, but we still generate it to test the fallback path) - "b1x1", - }; - - // Performance test cases (float and half) - return { - generate_dw_test_cases( - perf_configs, {vkapi::kFloat, vkapi::kHalf}, impls), - conv2d_dw_reference_impl, - conv2d_dw_flop_calculator}; -} - -} // namespace conv2d -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/conv2d/test_sets/pw_sets.cpp b/backends/vulkan/test/ops/benchmark/cases/conv2d/test_sets/pw_sets.cpp deleted file mode 100644 index d71ef21ba96..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/conv2d/test_sets/pw_sets.cpp +++ /dev/null @@ -1,129 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace conv2d { - -namespace { - -struct Conv2dPwCaseGroup { - std::vector configs; - std::vector dtypes; -}; - -std::vector generate_pw_test_cases( - const std::vector& groups) { - std::vector test_cases; - - for (const auto& group : groups) { - for (const auto& config : group.configs) { - for (auto dtype : group.dtypes) { - test_cases.push_back(create_conv2d_pw_test_case( - config, dtype, kStorageType, kMemoryLayout)); - } - } - } - - return test_cases; -} - -} // namespace - -REGISTER_TEST_CASE_SET("conv2d", "pw_correctness") { - // Accuracy shapes (small enough for float reference validation) - std::vector accuracy_configs = { - {1, 16, 32, 8, 8, false}, - {1, 32, 16, 8, 8, false}, - {1, 16, 32, 8, 8, true}, - // Non-multiple-of-4 channels - {1, 13, 27, 8, 8, false}, - {1, 33, 17, 8, 8, false}, - // Partial pixel and channel tiles, below and above the invocation count - // where Half switches to the wide tile - {1, 33, 52, 41, 47, true}, - {1, 33, 58, 63, 61, true}, - }; - - const std::vector groups = { - {accuracy_configs, {vkapi::kFloat, vkapi::kHalf}}, - }; - return { - generate_pw_test_cases(groups), - conv2d_pw_reference_impl, - conv2d_pw_flop_calculator}; -} - -REGISTER_TEST_CASE_SET("conv2d", "pw_performance") { - // Accuracy shapes (small enough for float reference validation) that - // create_conv2d_pw_test_case labels PERF, since a channel count exceeds - // kRefDimSizeLimit - std::vector accuracy_configs = { - {1, 48, 96, 16, 16, false}, - {1, 96, 48, 16, 16, false}, - }; - - // EdgeTAM performance shapes - std::vector perf_configs = { - // EdgeTAM backbone stages - {1, 48, 96, 256, 256, false}, - {1, 96, 48, 256, 256, false}, - {1, 96, 192, 128, 128, false}, - {1, 192, 96, 128, 128, false}, - {1, 192, 384, 64, 64, false}, - {1, 384, 192, 64, 64, false}, - {1, 384, 768, 32, 32, false}, - {1, 768, 384, 32, 32, false}, - // EdgeTAM FPN/Neck - {1, 48, 256, 256, 256, false}, - {1, 256, 32, 256, 256, false}, - {1, 96, 256, 128, 128, false}, - {1, 256, 64, 128, 128, false}, - }; - - const std::vector groups = { - // Accuracy test cases (float only) - {accuracy_configs, {vkapi::kFloat}}, - // Performance test cases (float and half) - {perf_configs, {vkapi::kFloat, vkapi::kHalf}}, - }; - return { - generate_pw_test_cases(groups), - conv2d_pw_reference_impl, - conv2d_pw_flop_calculator}; -} - -REGISTER_TEST_CASE_SET("conv2d", "dsr_pw_set") { - // DSR dim=64 1x1 convs at the 1512x2016 input resolution. - const std::vector configs = { - {1, 64, 64, 756, 1008, true}, - {1, 32, 128, 756, 1008, true}, - {1, 128, 32, 756, 1008, true}, - {1, 128, 128, 378, 504, true}, - {1, 64, 256, 378, 504, true}, - {1, 256, 64, 378, 504, true}, - {1, 256, 256, 189, 252, true}, - {1, 128, 512, 189, 252, true}, - {1, 512, 128, 189, 252, true}, - }; - // Full-resolution FP16 configurations are timing-only. - return { - generate_pw_test_cases({{configs, {vkapi::kHalf}}}), - nullptr, - conv2d_pw_flop_calculator, - 5, - 20}; -} - -} // namespace conv2d -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/embedding_q4gsw/embedding_q4gsw.cpp b/backends/vulkan/test/ops/benchmark/cases/embedding_q4gsw/embedding_q4gsw.cpp deleted file mode 100644 index a1e02529193..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/embedding_q4gsw/embedding_q4gsw.cpp +++ /dev/null @@ -1,200 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace embedding_q4gsw { - -TestCase create_test_case(const EmbeddingConfig& config) { - TestCase test_case; - - // Compute the output shape for label: indices_shape + [embed_dim] - std::vector output_shape_for_label = config.indices_shape; - output_shape_for_label.push_back(config.embed_dim); - - // Treat any case that uses the large llama vocab/embed sizes as PERF. - // Otherwise default to ACCU (no PERF distinction is exercised by this op). - bool is_perf = config.vocab_size > 1024 || config.embed_dim > 1024; - std::string prefix = is_perf ? "PERF" : "ACCU"; - - std::string in_dtype = dtype_short(vkapi::kInt); // indices are i32 - std::string out_dtype = dtype_short(config.dtype); - - std::string storage_str = repr_str(config.storage_type, utils::kWidthPacked); - - std::string shape_str = shape_bracket(config.indices_shape) + "x[" + - std::to_string(config.vocab_size) + "," + - std::to_string(config.embed_dim) + "]"; - shape_str += " g" + std::to_string(config.group_size); - if (config.is_linear_weight) { - shape_str += " lw"; - } - std::string suffix; - if (config.scales_dtype == vkapi::kFloat) { - suffix = "[f32_scales]"; - } - std::string name = make_test_label( - prefix, in_dtype, out_dtype, shape_str, storage_str, suffix); - test_case.set_name(name); - test_case.set_operator_name("et_vk.embedding_q4gsw.default"); - test_case.set_shader_filter({}); - - // Weight: [vocab_size, embed_dim / 2] packed uint8 - ValueSpec weight( - {config.vocab_size, config.embed_dim / 2}, - vkapi::kByte, - utils::kBuffer, - utils::kWidthPacked, - DataGenType::RANDINT4); - weight.set_constant(true); - test_case.add_input_spec(weight); - - // Weight scales: [vocab_size] for per-row, otherwise - // [vocab_size, groups_per_row]. - int64_t groups_per_row = config.embed_dim / config.group_size; - std::vector scales_shape = config.per_row_scales_1d - ? std::vector{config.vocab_size} - : std::vector{config.vocab_size, groups_per_row}; - ValueSpec weight_scales( - scales_shape, - config.scales_dtype, - utils::kBuffer, - utils::kWidthPacked, - DataGenType::RANDOM_SCALES); - weight_scales.set_constant(true); - test_case.add_input_spec(weight_scales); - - // Group size: int scalar - ValueSpec group_size_spec(static_cast(config.group_size)); - test_case.add_input_spec(group_size_spec); - - // Indices: [batch, seq_len] int32 - ValueSpec indices( - config.indices_shape, - vkapi::kInt, - config.storage_type, - utils::kWidthPacked, - DataGenType::RANDINT); - - // Clamp indices to valid vocab range - indices.ensure_data_generated(); - for (auto& idx : indices.get_int32_data()) { - idx = std::abs(idx) % config.vocab_size; - } - - test_case.add_input_spec(indices); - - // is_linear_weight: bool scalar - ValueSpec is_linear_weight_spec(config.is_linear_weight); - test_case.add_input_spec(is_linear_weight_spec); - - // Output: indices.shape + [embed_dim] - std::vector output_shape = config.indices_shape; - output_shape.push_back(config.embed_dim); - ValueSpec output( - output_shape, config.dtype, config.storage_type, utils::kWidthPacked); - test_case.add_output_spec(output); - - return test_case; -} - -// CPU reference: unpack 4-bit weights, dequantize, and perform embedding lookup -void embedding_4bit_reference(TestCase& tc) { - auto& weight_spec = tc.inputs()[0]; - auto& scales_spec = tc.inputs()[1]; - int32_t group_size = tc.inputs()[2].get_int_value(); - auto& indices_spec = tc.inputs()[3]; - bool is_linear_weight = tc.inputs()[4].get_bool_value(); - auto& output_spec = tc.outputs()[0]; - - weight_spec.ensure_data_generated(); - scales_spec.ensure_data_generated(); - indices_spec.ensure_data_generated(); - - const auto& weight_data = weight_spec.get_uint8_data(); - const auto& indices_data = indices_spec.get_int32_data(); - - bool scales_are_half = (scales_spec.dtype == vkapi::kHalf); - - int64_t packed_dim = weight_spec.sizes[1]; - int64_t embed_dim = packed_dim * 2; - int64_t groups_per_row = - scales_spec.sizes.size() == 1 ? 1 : scales_spec.sizes[1]; - - int64_t num_indices = 1; - for (auto s : indices_spec.sizes) { - num_indices *= s; - } - - int64_t total_output = num_indices * embed_dim; - - // Always populate ref_float_data so the caching framework can distribute it - output_spec.get_ref_float_data().resize(total_output); - - bool output_is_half = (output_spec.dtype == vkapi::kHalf); - if (output_is_half) { - output_spec.get_ref_half_data().resize(total_output); - } - - for (int64_t i = 0; i < num_indices; ++i) { - int32_t idx = indices_data[i]; - for (int64_t d = 0; d < embed_dim; ++d) { - int64_t packed_idx = d / 2; - uint8_t packed_byte = weight_data[idx * packed_dim + packed_idx]; - - // Unpack: packed_byte = (even_val + 8) << 4 | (odd_val + 8) - // Even d -> high nibble, odd d -> low nibble - // For linear weight packing, nibble order is swapped - int int4_val; - if (d % 2 == 0) { - if (is_linear_weight) { - int4_val = static_cast(packed_byte & 0xF) - 8; - } else { - int4_val = static_cast(packed_byte >> 4) - 8; - } - } else { - if (is_linear_weight) { - int4_val = static_cast(packed_byte >> 4) - 8; - } else { - int4_val = static_cast(packed_byte & 0xF) - 8; - } - } - - int64_t group_idx = d / group_size; - int64_t scale_idx = idx * groups_per_row + group_idx; - - float scale; - if (scales_are_half) { - uint16_t scale_half = scales_spec.get_half_data()[scale_idx]; - scale = half_to_float(scale_half); - } else { - scale = scales_spec.get_float_data()[scale_idx]; - } - - float result = static_cast(int4_val) * scale; - - // Always store float reference - output_spec.get_ref_float_data()[i * embed_dim + d] = result; - - if (output_is_half) { - output_spec.get_ref_half_data()[i * embed_dim + d] = - float_to_half(result); - } - } - } -} - -} // namespace embedding_q4gsw -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/embedding_q4gsw/embedding_q4gsw.h b/backends/vulkan/test/ops/benchmark/cases/embedding_q4gsw/embedding_q4gsw.h deleted file mode 100644 index a161653ce41..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/embedding_q4gsw/embedding_q4gsw.h +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#pragma once - -#include -#include - -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace embedding_q4gsw { - -struct EmbeddingConfig { - int64_t vocab_size; - int64_t embed_dim; - int64_t group_size; - std::vector indices_shape; - std::string test_case_name = "placeholder"; - vkapi::ScalarType dtype = vkapi::kHalf; - vkapi::ScalarType scales_dtype = vkapi::kHalf; - utils::StorageType storage_type = utils::kBuffer; - bool is_linear_weight = false; - bool per_row_scales_1d = false; -}; - -TestCase create_test_case(const EmbeddingConfig& config); - -void embedding_4bit_reference(TestCase& tc); - -} // namespace embedding_q4gsw -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/embedding_q4gsw/test_sets/embedding_q4gsw_sets.cpp b/backends/vulkan/test/ops/benchmark/cases/embedding_q4gsw/test_sets/embedding_q4gsw_sets.cpp deleted file mode 100644 index 764a34102cf..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/embedding_q4gsw/test_sets/embedding_q4gsw_sets.cpp +++ /dev/null @@ -1,353 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace embedding_q4gsw { - -namespace { - -std::vector generate_test_cases( - const std::vector& configs) { - std::vector test_cases; - test_cases.reserve(configs.size()); - for (const auto& config : configs) { - test_cases.push_back(create_test_case(config)); - } - return test_cases; -} - -} // namespace - -REGISTER_TEST_CASE_SET("embedding_q4gsw", "correctness") { - const std::vector configs = { - // --- is_linear_weight = true --- - - // Basic test with linear weight packing - {.vocab_size = 16, - .embed_dim = 32, - .group_size = 32, - .indices_shape = {4}, - .test_case_name = "small_1d_linear_weight", - .is_linear_weight = true}, - - {.vocab_size = 32, - .embed_dim = 64, - .group_size = 32, - .indices_shape = {2, 3}, - .test_case_name = "small_2d_linear_weight", - .is_linear_weight = true}, - - {.vocab_size = 100, - .embed_dim = 128, - .group_size = 32, - .indices_shape = {4, 8}, - .test_case_name = "medium_multigroup_linear_weight", - .is_linear_weight = true}, - - // fp32 output with linear weight - {.vocab_size = 16, - .embed_dim = 32, - .group_size = 32, - .indices_shape = {4}, - .test_case_name = "small_1d_fp32_linear_weight", - .dtype = vkapi::kFloat, - .is_linear_weight = true}, - - // Texture3D with linear weight - {.vocab_size = 16, - .embed_dim = 32, - .group_size = 32, - .indices_shape = {4}, - .test_case_name = "small_1d_texture_linear_weight", - .storage_type = utils::kTexture3D, - .is_linear_weight = true}, - - // --- Half scales (default) --- - - // Basic test: small vocab, small embed_dim - {.vocab_size = 16, - .embed_dim = 32, - .group_size = 32, - .indices_shape = {4}, - .test_case_name = "small_1d"}, - - // 2D indices - {.vocab_size = 32, - .embed_dim = 64, - .group_size = 32, - .indices_shape = {2, 3}, - .test_case_name = "small_2d"}, - - // Larger vocab, multiple groups - {.vocab_size = 100, - .embed_dim = 128, - .group_size = 32, - .indices_shape = {4, 8}, - .test_case_name = "medium_multigroup"}, - - // group_size == embed_dim (single group) - {.vocab_size = 50, - .embed_dim = 64, - .group_size = 64, - .indices_shape = {2, 4}, - .test_case_name = "single_group"}, - - // fp32 output variants (half scales) - {.vocab_size = 16, - .embed_dim = 32, - .group_size = 32, - .indices_shape = {4}, - .test_case_name = "small_1d_fp32", - .dtype = vkapi::kFloat}, - - {.vocab_size = 32, - .embed_dim = 64, - .group_size = 32, - .indices_shape = {2, 3}, - .test_case_name = "small_2d_fp32", - .dtype = vkapi::kFloat}, - - {.vocab_size = 100, - .embed_dim = 128, - .group_size = 32, - .indices_shape = {4, 8}, - .test_case_name = "medium_multigroup_fp32", - .dtype = vkapi::kFloat}, - - {.vocab_size = 50, - .embed_dim = 64, - .group_size = 64, - .indices_shape = {2, 4}, - .test_case_name = "single_group_fp32", - .dtype = vkapi::kFloat}, - - // Texture3D variants (fp16 output, half scales) - {.vocab_size = 16, - .embed_dim = 32, - .group_size = 32, - .indices_shape = {4}, - .test_case_name = "small_1d_texture", - .dtype = vkapi::kHalf, - .storage_type = utils::kTexture3D}, - - {.vocab_size = 32, - .embed_dim = 64, - .group_size = 32, - .indices_shape = {2, 3}, - .test_case_name = "small_2d_texture", - .dtype = vkapi::kHalf, - .storage_type = utils::kTexture3D}, - - {.vocab_size = 100, - .embed_dim = 128, - .group_size = 32, - .indices_shape = {4, 8}, - .test_case_name = "medium_multigroup_texture", - .dtype = vkapi::kHalf, - .storage_type = utils::kTexture3D}, - - {.vocab_size = 50, - .embed_dim = 64, - .group_size = 64, - .indices_shape = {2, 4}, - .test_case_name = "single_group_texture", - .dtype = vkapi::kHalf, - .storage_type = utils::kTexture3D}, - - // Texture3D variants (fp32 output, half scales) - {.vocab_size = 16, - .embed_dim = 32, - .group_size = 32, - .indices_shape = {4}, - .test_case_name = "small_1d_fp32_texture", - .dtype = vkapi::kFloat, - .storage_type = utils::kTexture3D}, - - {.vocab_size = 32, - .embed_dim = 64, - .group_size = 32, - .indices_shape = {2, 3}, - .test_case_name = "small_2d_fp32_texture", - .dtype = vkapi::kFloat, - .storage_type = utils::kTexture3D}, - - {.vocab_size = 100, - .embed_dim = 128, - .group_size = 32, - .indices_shape = {4, 8}, - .test_case_name = "medium_multigroup_fp32_texture", - .dtype = vkapi::kFloat, - .storage_type = utils::kTexture3D}, - - {.vocab_size = 50, - .embed_dim = 64, - .group_size = 64, - .indices_shape = {2, 4}, - .test_case_name = "single_group_fp32_texture", - .dtype = vkapi::kFloat, - .storage_type = utils::kTexture3D}, - - // --- Float scales --- - - // Buffer variants with float scales - {.vocab_size = 16, - .embed_dim = 32, - .group_size = 32, - .indices_shape = {4}, - .test_case_name = "small_1d_float_scales", - .dtype = vkapi::kHalf, - .scales_dtype = vkapi::kFloat}, - - {.vocab_size = 32, - .embed_dim = 64, - .group_size = 32, - .indices_shape = {2, 3}, - .test_case_name = "small_2d_float_scales", - .dtype = vkapi::kHalf, - .scales_dtype = vkapi::kFloat}, - - {.vocab_size = 100, - .embed_dim = 128, - .group_size = 32, - .indices_shape = {4, 8}, - .test_case_name = "medium_multigroup_float_scales", - .dtype = vkapi::kHalf, - .scales_dtype = vkapi::kFloat}, - - {.vocab_size = 16, - .embed_dim = 32, - .group_size = 32, - .indices_shape = {4}, - .test_case_name = "small_1d_fp32_float_scales", - .dtype = vkapi::kFloat, - .scales_dtype = vkapi::kFloat}, - - {.vocab_size = 32, - .embed_dim = 64, - .group_size = 32, - .indices_shape = {2, 3}, - .test_case_name = "small_2d_fp32_float_scales", - .dtype = vkapi::kFloat, - .scales_dtype = vkapi::kFloat}, - - {.vocab_size = 100, - .embed_dim = 128, - .group_size = 32, - .indices_shape = {4, 8}, - .test_case_name = "medium_multigroup_fp32_float_scales", - .dtype = vkapi::kFloat, - .scales_dtype = vkapi::kFloat}, - - // Texture3D with float scales - {.vocab_size = 16, - .embed_dim = 32, - .group_size = 32, - .indices_shape = {4}, - .test_case_name = "small_1d_float_scales_texture", - .dtype = vkapi::kHalf, - .scales_dtype = vkapi::kFloat, - .storage_type = utils::kTexture3D}, - - {.vocab_size = 16, - .embed_dim = 32, - .group_size = 32, - .indices_shape = {4}, - .test_case_name = "small_1d_fp32_float_scales_texture", - .dtype = vkapi::kFloat, - .scales_dtype = vkapi::kFloat, - .storage_type = utils::kTexture3D}, - }; - auto test_cases = generate_test_cases(configs); - for (const auto storage_type : {utils::kBuffer, utils::kTexture3D}) { - test_cases.push_back(create_test_case( - {.vocab_size = 16, - .embed_dim = 1536, - .group_size = 1536, - .indices_shape = {1, 4}, - .test_case_name = "gemma4_token_embedding_per_row", - .dtype = vkapi::kFloat, - .scales_dtype = vkapi::kFloat, - .storage_type = storage_type, - .is_linear_weight = true, - .per_row_scales_1d = true})); - test_cases.push_back(create_test_case( - {.vocab_size = 16, - .embed_dim = 8960, - .group_size = 8960, - .indices_shape = {1, 4}, - .test_case_name = "gemma4_ple_embedding_per_row", - .dtype = vkapi::kFloat, - .scales_dtype = vkapi::kFloat, - .storage_type = storage_type, - .is_linear_weight = true, - .per_row_scales_1d = true})); - } - return {test_cases, embedding_4bit_reference}; -} - -REGISTER_TEST_CASE_SET("embedding_q4gsw", "performance") { - const std::vector configs = { - // Llama 3.2 1B with linear weight packing - {.vocab_size = 128256, - .embed_dim = 2048, - .group_size = 32, - .indices_shape = {1, 2047}, - .test_case_name = "llama_3_2_1b_prefill_fp32_linear_weight", - .dtype = vkapi::kFloat, - .storage_type = utils::kBuffer, - .is_linear_weight = true}, - - // Llama 3.2 1B prefill configuration (fp32 output, half scales) - {.vocab_size = 128256, - .embed_dim = 2048, - .group_size = 32, - .indices_shape = {1, 2047}, - .test_case_name = "llama_3_2_1b_prefill_fp32", - .dtype = vkapi::kFloat, - .storage_type = utils::kBuffer}, - - // Llama 3.2 1B prefill configuration (fp32 output, float scales) - {.vocab_size = 128256, - .embed_dim = 2048, - .group_size = 32, - .indices_shape = {1, 2047}, - .test_case_name = "llama_3_2_1b_prefill_fp32_float_scales", - .dtype = vkapi::kFloat, - .scales_dtype = vkapi::kFloat, - .storage_type = utils::kBuffer}, - - // Llama 3.2 1B prefill configuration (fp16 output, half scales) - {.vocab_size = 128256, - .embed_dim = 2048, - .group_size = 32, - .indices_shape = {1, 2047}, - .test_case_name = "llama_3_2_1b_prefill_fp16", - .dtype = vkapi::kHalf, - .storage_type = utils::kBuffer}, - - // Llama 3.2 1B prefill configuration (fp16 output, float scales) - {.vocab_size = 128256, - .embed_dim = 2048, - .group_size = 32, - .indices_shape = {1, 2047}, - .test_case_name = "llama_3_2_1b_prefill_fp16_float_scales", - .dtype = vkapi::kHalf, - .scales_dtype = vkapi::kFloat, - .storage_type = utils::kBuffer}, - }; - return {generate_test_cases(configs), embedding_4bit_reference}; -} - -} // namespace embedding_q4gsw -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/mm/mm.cpp b/backends/vulkan/test/ops/benchmark/cases/mm/mm.cpp deleted file mode 100644 index 0526eac1e42..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/mm/mm.cpp +++ /dev/null @@ -1,423 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace mm { - -TestCase create_mm_test_case( - const MmConfig& config, - vkapi::ScalarType dtype, - utils::StorageType storage_type, - utils::GPUMemoryLayout memory_layout) { - TestCase test_case; - - bool is_batched = config.B > 0; - bool is_perf = config.M > kRefDimSizeLimit || config.K > kRefDimSizeLimit || - config.N > kRefDimSizeLimit; - - std::string prefix = is_perf ? "PERF" : "ACCU"; - std::string storage_str = repr_str(storage_type, utils::kWidthPacked); - std::string dtype_str = dtype_short(dtype); - - // Determine op type string - std::string op_type; - if (config.mat2_is_transposed) { - op_type = config.has_bias ? "linear-bias" : "linear"; - } else if (config.has_bias) { - op_type = config.mat2_is_constant ? "addmm-const-mat2" : "addmm"; - } else if (is_batched) { - op_type = config.mat2_is_constant ? "bmm-const-mat2" : "bmm"; - } else { - op_type = config.mat2_is_constant ? "mm-const-mat2" : "mm"; - } - - // Build shape string - std::string shape; - if (is_batched) { - shape = "[" + std::to_string(config.B) + "," + std::to_string(config.M) + - "," + std::to_string(config.K) + "]x[" + std::to_string(config.B) + - "," + std::to_string(config.K) + "," + std::to_string(config.N) + "]"; - } else if (config.mat2_is_transposed) { - shape = "[" + std::to_string(config.M) + "," + std::to_string(config.K) + - "]x[" + std::to_string(config.N) + "," + std::to_string(config.K) + "]"; - } else { - shape = "[" + std::to_string(config.M) + "," + std::to_string(config.K) + - "]x[" + std::to_string(config.K) + "," + std::to_string(config.N) + "]"; - } - - std::string suffix = "[" + op_type + "]"; - if (config.impl_selector != "default") { - suffix += " [" + config.impl_selector + "]"; - } - - std::string name = - make_test_label(prefix, dtype_str, dtype_str, shape, storage_str, suffix); - - test_case.set_name(name); - - // Determine op name - use test wrapper operators - std::string op_name; - if (is_batched) { - op_name = "test_etvk.test_bmm.default"; - } else if (config.mat2_is_transposed) { - op_name = "test_etvk.test_linear.default"; - } else if (config.has_bias) { - op_name = "test_etvk.test_addmm.default"; - } else { - op_name = "test_etvk.test_mm.default"; - } - test_case.set_operator_name(op_name); - - // mat1 - std::vector mat1_sizes; - if (is_batched) { - mat1_sizes = {config.B, config.M, config.K}; - } else { - mat1_sizes = {config.M, config.K}; - } - ValueSpec mat1( - mat1_sizes, dtype, storage_type, memory_layout, DataGenType::RANDOM); - - // mat2 - for linear, weight is [N, K] (transposed) - std::vector mat2_sizes; - if (config.mat2_is_transposed) { - mat2_sizes = {config.N, config.K}; - } else if (is_batched) { - mat2_sizes = {config.B, config.K, config.N}; - } else { - mat2_sizes = {config.K, config.N}; - } - - if (config.mat2_is_transposed) { - // For linear, weight is a constant tensor - ValueSpec mat2( - mat2_sizes, dtype, storage_type, memory_layout, DataGenType::RANDOM); - mat2.set_constant(true); - - // bias (or none) - if (config.has_bias) { - ValueSpec bias( - {config.N}, dtype, storage_type, memory_layout, DataGenType::RANDOM); - bias.set_constant(true); - - // test_etvk.test_linear.default: input, weight, bias, impl_selector, out - test_case.add_input_spec(mat1); - test_case.add_input_spec(mat2); - test_case.add_input_spec(bias); - } else { - test_case.add_input_spec(mat1); - test_case.add_input_spec(mat2); - // Use an int spec marked as none to avoid being treated as a tensor - ValueSpec none_bias(static_cast(0)); - none_bias.set_none(true); - test_case.add_input_spec(none_bias); - } - } else if (config.has_bias) { - // test_etvk.test_addmm.default: self, mat1, mat2, beta, alpha, - // impl_selector, out - ValueSpec bias( - {config.N}, dtype, storage_type, memory_layout, DataGenType::RANDOM); - ValueSpec mat2( - mat2_sizes, dtype, storage_type, memory_layout, DataGenType::RANDOM); - if (config.mat2_is_constant) { - mat2.set_constant(true); - } - - test_case.add_input_spec(bias); - test_case.add_input_spec(mat1); - test_case.add_input_spec(mat2); - // beta - test_case.add_input_spec(ValueSpec(1.0f)); - // alpha - test_case.add_input_spec(ValueSpec(1.0f)); - } else { - // test_etvk.test_mm.default or test_etvk.test_bmm.default: - // mat1, mat2, impl_selector, out - ValueSpec mat2( - mat2_sizes, dtype, storage_type, memory_layout, DataGenType::RANDOM); - if (config.mat2_is_constant) { - mat2.set_constant(true); - } - test_case.add_input_spec(mat1); - test_case.add_input_spec(mat2); - } - - // impl_selector (added before output for all variants) - ValueSpec impl_selector_spec = ValueSpec::make_string(config.impl_selector); - test_case.add_input_spec(impl_selector_spec); - - // output - std::vector out_sizes; - if (is_batched) { - out_sizes = {config.B, config.M, config.N}; - } else { - out_sizes = {config.M, config.N}; - } - ValueSpec output( - out_sizes, dtype, storage_type, memory_layout, DataGenType::ZEROS); - test_case.add_output_spec(output); - - // The coopmat shader uses fp16 intermediates regardless of input dtype - // (inputs are packHalf2x16-converted before entering the MMA), so the - // achievable precision is fp16-bounded for any path that dispatches to - // it. A coopmat dispatch occurs when impl_selector forces it, or when - // the default routing's gate (buffer + M/N/K alignment) is met. - bool routes_to_coopmat = false; - if (config.impl_selector == "coopmat") { - routes_to_coopmat = true; - } else if ( - config.impl_selector == "default" && storage_type == utils::kBuffer && - !is_batched && config.M % 64 == 0 && config.N % 64 == 0 && - config.K % 32 == 0) { - // Mirror is_coopmat_eligible's full device-capability gate so default - // routing's tolerance matches its actual dispatch path. On a device - // that's integrated, subgroup-32, or otherwise ineligible, the default - // routes to tiled and tolerance must stay tight. - const auto* adapter = api::context()->adapter_ptr(); - routes_to_coopmat = adapter->supports_cooperative_matrix() && - adapter->subgroup_size() == 64 && !adapter->is_integrated_gpu(); - } - - if (dtype == vkapi::kHalf) { - // Pure-fp16 GEMM accumulates K products in fp16 (no fp32 accumulator on - // the tiled path), so error scales with ULP * sqrt(K) * output magnitude. - // For K up to 4096 in these tests with inputs in [-1, 1], that lands - // around 6% relative error empirically; 10% covers worst-case alignment - // of rounding signs without masking real shader bugs. - test_case.set_abs_tolerance(1.0f); - test_case.set_rel_tolerance(1e-1f); - } else if (routes_to_coopmat) { - // Coopmat uses fp16 inputs with an fp32 accumulator, so error is - // bounded by fp16 input-conversion noise, not accumulation drift. - test_case.set_abs_tolerance(1e-1f); - test_case.set_rel_tolerance(1e-2f); - } else { - test_case.set_abs_tolerance(1e-3f); - test_case.set_rel_tolerance(1e-3f); - } - - // Filter out layout conversion shaders from timing - test_case.set_shader_filter({"nchw_to", "to_nchw", "view_copy"}); - - return test_case; -} - -int64_t mm_flop_calculator(const TestCase& test_case) { - const auto& out_sizes = test_case.outputs()[0].get_tensor_sizes(); - const std::string& op_name = test_case.operator_name(); - - int64_t M, N, K; - - if (op_name == "test_etvk.test_mm.default") { - // mat1[0], mat2[1], impl_selector[2] - auto mat1_sizes = test_case.inputs()[0].get_tensor_sizes(); - M = mat1_sizes[0]; - K = mat1_sizes[1]; - N = out_sizes[1]; - return 2 * M * K * N; - } else if (op_name == "test_etvk.test_bmm.default") { - // mat1[0], mat2[1], impl_selector[2] - auto mat1_sizes = test_case.inputs()[0].get_tensor_sizes(); - int64_t B = mat1_sizes[0]; - M = mat1_sizes[1]; - K = mat1_sizes[2]; - N = out_sizes[2]; - return 2 * B * M * K * N; - } else if (op_name == "test_etvk.test_addmm.default") { - // self[0], mat1[1], mat2[2], beta[3], alpha[4], impl_selector[5] - auto mat1_sizes = test_case.inputs()[1].get_tensor_sizes(); - M = mat1_sizes[0]; - K = mat1_sizes[1]; - N = out_sizes[1]; - return 2 * M * K * N; - } else if (op_name == "test_etvk.test_linear.default") { - // input[0], weight[1], bias[2], impl_selector[3] - auto input_sizes = test_case.inputs()[0].get_tensor_sizes(); - auto weight_sizes = test_case.inputs()[1].get_tensor_sizes(); - M = input_sizes[0]; - K = input_sizes[1]; - N = weight_sizes[0]; - return 2 * M * K * N; - } - return 1; -} - -namespace { - -// Return the input data as float regardless of dtype. For kHalf, the -// underlying half_data is converted via half_to_float; for kFloat the -// existing float_data is copied. The CPU reference computes in float and -// the framework compares against (and converts back to) the GPU output. -std::vector as_float_data(const ValueSpec& spec) { - if (spec.dtype == vkapi::kFloat) { - return spec.get_float_data(); - } - if (spec.dtype == vkapi::kHalf) { - const auto& half_bits = spec.get_half_data(); - std::vector out(half_bits.size()); - for (size_t i = 0; i < half_bits.size(); ++i) { - out[i] = half_to_float(half_bits[i]); - } - return out; - } - throw std::invalid_argument("as_float_data: unsupported dtype"); -} - -// Reference implementation for mm/bmm -// Input layout per test operator: -// test_mm/test_bmm: mat1[0], mat2[1], impl_selector[2] -// test_addmm: self[0], mat1[1], mat2[2], beta[3], alpha[4], impl_selector[5] -// test_linear: input[0], weight[1], bias[2], impl_selector[3] -void mm_reference_impl(TestCase& test_case) { - const std::string& op_name = test_case.operator_name(); - ValueSpec& output = test_case.outputs()[0]; - auto out_sizes = output.get_tensor_sizes(); - - const auto in_dtype = test_case.inputs()[0].dtype; - if (in_dtype != vkapi::kFloat && in_dtype != vkapi::kHalf) { - throw std::invalid_argument("Reference only supports float / half"); - } - - if (op_name == "test_etvk.test_mm.default") { - // mat1[0], mat2[1], impl_selector[2] - const auto& mat1 = test_case.inputs()[0]; - const auto& mat2 = test_case.inputs()[1]; - auto mat1_sizes = mat1.get_tensor_sizes(); - auto mat2_sizes = mat2.get_tensor_sizes(); - - int64_t M = mat1_sizes[0]; - int64_t K = mat1_sizes[1]; - int64_t N = mat2_sizes[1]; - - auto mat1_data = as_float_data(mat1); - auto mat2_data = as_float_data(mat2); - auto& ref_data = output.get_ref_float_data(); - ref_data.resize(M * N, 0.0f); - - for (int64_t m = 0; m < M; ++m) { - for (int64_t n = 0; n < N; ++n) { - float sum = 0.0f; - for (int64_t k = 0; k < K; ++k) { - sum += mat1_data[m * K + k] * mat2_data[k * N + n]; - } - ref_data[m * N + n] = sum; - } - } - } else if (op_name == "test_etvk.test_bmm.default") { - // mat1[0], mat2[1], impl_selector[2] - const auto& mat1 = test_case.inputs()[0]; - const auto& mat2 = test_case.inputs()[1]; - auto mat1_sizes = mat1.get_tensor_sizes(); - auto mat2_sizes = mat2.get_tensor_sizes(); - - int64_t B = mat1_sizes[0]; - int64_t M = mat1_sizes[1]; - int64_t K = mat1_sizes[2]; - int64_t N = mat2_sizes[2]; - - auto mat1_data = as_float_data(mat1); - auto mat2_data = as_float_data(mat2); - auto& ref_data = output.get_ref_float_data(); - ref_data.resize(B * M * N, 0.0f); - - for (int64_t b = 0; b < B; ++b) { - for (int64_t m = 0; m < M; ++m) { - for (int64_t n = 0; n < N; ++n) { - float sum = 0.0f; - for (int64_t k = 0; k < K; ++k) { - sum += mat1_data[b * M * K + m * K + k] * - mat2_data[b * K * N + k * N + n]; - } - ref_data[b * M * N + m * N + n] = sum; - } - } - } - } else if (op_name == "test_etvk.test_addmm.default") { - // self[0], mat1[1], mat2[2], beta[3], alpha[4], impl_selector[5] - const auto& bias = test_case.inputs()[0]; - const auto& mat1 = test_case.inputs()[1]; - const auto& mat2 = test_case.inputs()[2]; - auto mat1_sizes = mat1.get_tensor_sizes(); - auto mat2_sizes = mat2.get_tensor_sizes(); - - int64_t M = mat1_sizes[0]; - int64_t K = mat1_sizes[1]; - int64_t N = mat2_sizes[1]; - - auto bias_data = as_float_data(bias); - auto mat1_data = as_float_data(mat1); - auto mat2_data = as_float_data(mat2); - auto& ref_data = output.get_ref_float_data(); - ref_data.resize(M * N, 0.0f); - - float alpha = test_case.inputs()[4].get_float_value(); - float beta = test_case.inputs()[3].get_float_value(); - - for (int64_t m = 0; m < M; ++m) { - for (int64_t n = 0; n < N; ++n) { - float sum = 0.0f; - for (int64_t k = 0; k < K; ++k) { - sum += mat1_data[m * K + k] * mat2_data[k * N + n]; - } - float bias_val = - (n < static_cast(bias_data.size())) ? bias_data[n] : 0.0f; - ref_data[m * N + n] = beta * bias_val + alpha * sum; - } - } - } else if (op_name == "test_etvk.test_linear.default") { - // input[0], weight[1], bias[2], impl_selector[3] - const auto& input = test_case.inputs()[0]; - const auto& weight = test_case.inputs()[1]; - const auto& bias_spec = test_case.inputs()[2]; - auto input_sizes = input.get_tensor_sizes(); - auto weight_sizes = weight.get_tensor_sizes(); - - int64_t M = input_sizes[0]; - int64_t K = input_sizes[1]; - int64_t N = weight_sizes[0]; - - auto input_data = as_float_data(input); - auto weight_data = as_float_data(weight); - std::vector bias_data; - if (!bias_spec.is_none()) { - bias_data = as_float_data(bias_spec); - } - auto& ref_data = output.get_ref_float_data(); - ref_data.resize(M * N, 0.0f); - - for (int64_t m = 0; m < M; ++m) { - for (int64_t n = 0; n < N; ++n) { - float sum = 0.0f; - for (int64_t k = 0; k < K; ++k) { - sum += input_data[m * K + k] * weight_data[n * K + k]; - } - if (!bias_data.empty()) { - sum += bias_data[n]; - } - ref_data[m * N + n] = sum; - } - } - } -} - -} // namespace - -void reference_impl(TestCase& test_case) { - mm_reference_impl(test_case); -} - -} // namespace mm -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/mm/mm.h b/backends/vulkan/test/ops/benchmark/cases/mm/mm.h deleted file mode 100644 index b4ec8f21795..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/mm/mm.h +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#pragma once - -#include -#include - -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace mm { - -constexpr int64_t kRefDimSizeLimit = 256; - -struct MmConfig { - // mat1: [M, K] or [B, M, K] - // mat2: [K, N] or [B, K, N] - int64_t B; // batch size, 0 for non-batched - int64_t M; - int64_t K; - int64_t N; - bool has_bias; // true for addmm/linear - bool mat2_is_transposed; // true for linear (weight is [N, K]) - bool mat2_is_constant; // true to test prepacked linear path - // "default" routes through aten.{mm,linear,...}.default (production path). - // "coopmat" / "tiled" force-dispatch a specific shader implementation. - std::string impl_selector = "default"; -}; - -TestCase create_mm_test_case( - const MmConfig& config, - vkapi::ScalarType dtype, - utils::StorageType storage_type, - utils::GPUMemoryLayout memory_layout); - -void reference_impl(TestCase& test_case); - -int64_t mm_flop_calculator(const TestCase& test_case); - -} // namespace mm -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/mm/test_sets/mm_sets.cpp b/backends/vulkan/test/ops/benchmark/cases/mm/test_sets/mm_sets.cpp deleted file mode 100644 index 232e93d326f..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/mm/test_sets/mm_sets.cpp +++ /dev/null @@ -1,171 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace mm { - -namespace { - -struct MmShape { - int64_t B, M, K, N; -}; - -// Coopmat shader requires M%64==0, N%64==0, K%32==0 (no partial-tile or K-tail -// handling). The force-dispatch selector sweep runs only for shapes that -// satisfy alignment, on buffer storage only (coopmat requires buffer outputs). -bool coopmat_shape_eligible(const MmShape& s) { - return s.B == 0 && s.M % 64 == 0 && s.N % 64 == 0 && s.K % 32 == 0; -} - -std::vector generate_mm_test_cases( - const std::vector& shapes, - const std::vector& dtypes, - const std::vector& storage_types, - const std::vector& coopmat_sweep_selectors) { - std::vector test_cases; - - // The "coopmat" forced-dispatch sweep skips the runtime eligibility check - // (it's intentionally bypassing the gate to exercise the shader). Skip - // generating those cases on adapters that can't actually run the shader, - // or pipeline creation/dispatch will fail. - const auto* adapter = api::context()->adapter_ptr(); - const bool coopmat_runnable = - adapter->supports_cooperative_matrix() && adapter->subgroup_size() == 64; - std::vector sweep_selectors; - for (const auto& sel : coopmat_sweep_selectors) { - if (sel != "coopmat" || coopmat_runnable) { - sweep_selectors.push_back(sel); - } - } - - for (const auto& s : shapes) { - bool is_batched = s.B > 0; - - MmConfig dynamic_cfg{s.B, s.M, s.K, s.N, false, false, false}; - MmConfig const_cfg{s.B, s.M, s.K, s.N, false, false, true}; - - for (auto dtype : dtypes) { - for (auto st : storage_types) { - test_cases.push_back( - create_mm_test_case(dynamic_cfg, dtype, st, utils::kWidthPacked)); - test_cases.push_back( - create_mm_test_case(const_cfg, dtype, st, utils::kWidthPacked)); - } - - // Coopmat A/B sweep: only on aligned shapes + buffer storage. - if (coopmat_shape_eligible(s)) { - for (const auto& sel : sweep_selectors) { - MmConfig dyn = dynamic_cfg; - dyn.impl_selector = sel; - test_cases.push_back(create_mm_test_case( - dyn, dtype, utils::kBuffer, utils::kWidthPacked)); - MmConfig con = const_cfg; - con.impl_selector = sel; - test_cases.push_back(create_mm_test_case( - con, dtype, utils::kBuffer, utils::kWidthPacked)); - } - } - - if (!is_batched) { - MmConfig addmm_cfg{s.B, s.M, s.K, s.N, true, false, false}; - MmConfig addmm_const_cfg{s.B, s.M, s.K, s.N, true, false, true}; - MmConfig linear_cfg{s.B, s.M, s.K, s.N, false, true, false}; - MmConfig linear_bias_cfg{s.B, s.M, s.K, s.N, true, true, false}; - - for (auto st : storage_types) { - test_cases.push_back( - create_mm_test_case(addmm_cfg, dtype, st, utils::kWidthPacked)); - test_cases.push_back(create_mm_test_case( - addmm_const_cfg, dtype, st, utils::kWidthPacked)); - test_cases.push_back( - create_mm_test_case(linear_cfg, dtype, st, utils::kWidthPacked)); - test_cases.push_back(create_mm_test_case( - linear_bias_cfg, dtype, st, utils::kWidthPacked)); - } - - // Coopmat A/B sweep on linear paths too (only aligned shapes). - if (coopmat_shape_eligible(s)) { - for (const auto& sel : sweep_selectors) { - MmConfig lin = linear_cfg; - lin.impl_selector = sel; - test_cases.push_back(create_mm_test_case( - lin, dtype, utils::kBuffer, utils::kWidthPacked)); - MmConfig lin_bias = linear_bias_cfg; - lin_bias.impl_selector = sel; - test_cases.push_back(create_mm_test_case( - lin_bias, dtype, utils::kBuffer, utils::kWidthPacked)); - } - } - } - } - } - - return test_cases; -} - -} // namespace - -REGISTER_TEST_CASE_SET("mm", "correctness") { - const std::vector shapes = { - // Accuracy shapes (float) - {0, 64, 128, 64}, - {0, 128, 256, 128}, - {0, 32, 64, 256}, - {1, 64, 128, 64}, - {1, 4, 32, 16}, - // Non-multiple-of-4 accuracy shapes (exercises scalar shader fallback) - {0, 57, 131, 43}, - {0, 33, 67, 91}, - {1, 19, 53, 37}, - {0, 64, 128, 47}, // only N unaligned - {0, 64, 47, 128}, // only K unaligned - }; - const std::vector dtypes = {vkapi::kFloat}; - const std::vector storage_types = { - utils::kTexture3D, utils::kBuffer}; - const std::vector coopmat_sweep_selectors = {"tiled", "coopmat"}; - - return { - generate_mm_test_cases( - shapes, dtypes, storage_types, coopmat_sweep_selectors), - reference_impl, - mm_flop_calculator}; -} - -REGISTER_TEST_CASE_SET("mm", "performance") { - const std::vector shapes = { - // Performance shapes (half) - {0, 4096, 1024, 256}, - {0, 4096, 256, 128}, - {0, 4096, 128, 256}, - {1, 4096, 256, 1024}, - {1, 4096, 256, 128}, - {1, 256, 4096, 64}, - {0, 4096, 64, 128}, - }; - const std::vector dtypes = {vkapi::kFloat, vkapi::kHalf}; - const std::vector storage_types = { - utils::kTexture3D, utils::kBuffer}; - const std::vector coopmat_sweep_selectors = {"tiled", "coopmat"}; - - return { - generate_mm_test_cases( - shapes, dtypes, storage_types, coopmat_sweep_selectors), - reference_impl, - mm_flop_calculator}; -} - -} // namespace mm -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q4gsw_linear/q4gsw_linear.cpp b/backends/vulkan/test/ops/benchmark/cases/q4gsw_linear/q4gsw_linear.cpp deleted file mode 100644 index 47ab929c38c..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q4gsw_linear/q4gsw_linear.cpp +++ /dev/null @@ -1,900 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include -#include -#include -#include -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q4gsw_linear { - -// Utility function to create a test case from a LinearConfig -TestCase create_test_case_from_config( - const LinearConfig& config, - utils::StorageType storage_type, - vkapi::ScalarType input_dtype) { - TestCase test_case; - - // Create a descriptive name for the test case - bool is_perf = - !(config.M < kRefDimSizeLimit && config.K < kRefDimSizeLimit && - config.N < kRefDimSizeLimit); - std::string prefix = is_perf ? "PERF" : "ACCU"; - std::string dtype_str = dtype_short(input_dtype); - std::string shape_str = "[" + std::to_string(config.M) + "," + - std::to_string(config.K) + "]x[" + std::to_string(config.N) + "," + - std::to_string(config.K) + "] g" + std::to_string(config.group_size); - std::string storage_str = repr_str(storage_type, utils::kWidthPacked); - std::string suffix = "[" + config.op_name + "]"; - if (!config.has_bias) { - suffix += " no_bias"; - } - std::string test_name = make_test_label( - prefix, dtype_str, dtype_str, shape_str, storage_str, suffix); - test_case.set_name(test_name); - - // Set the operator name for the test case - std::string operator_name = "et_vk." + config.op_name + ".default"; - test_case.set_operator_name(operator_name); - - // Derive sizes from M, K, N - std::vector input_size = {config.M, config.K}; - // Input tensor (float/half) - [M, K] - ValueSpec input_tensor( - input_size, - input_dtype, - storage_type, - utils::kWidthPacked, - DataGenType::RANDINT); - - if (debugging()) { - print_valuespec_data(input_tensor, "input_tensor"); - } - - // For activation+weight quantized linear (linear_dq8ca_q4gsw) - // Input scale and zero point as per-input channel tensors - ValueSpec input_scale( - {1, config.M}, // Per-input channel tensor - input_dtype, - storage_type, - utils::kWidthPacked, - DataGenType::RANDOM_SCALES); - input_scale.set_constant(true); - - ValueSpec input_zero_point( - {1, config.M}, // Per-input channel tensor - vkapi::kChar, - storage_type, - utils::kWidthPacked, - DataGenType::RANDINT); - input_zero_point.set_constant(true); - - // For 4-bit weights, packed size is [N, K/2] since 2 weights per byte - std::vector weight_size = {config.N, config.K / 2}; - // Quantized weight tensor (uint8, packed 4-bit) - [N, K/2] - ValueSpec quantized_weight( - weight_size, - vkapi::kByte, // uint8 for packed 4-bit quantized weights - storage_type, - utils::kWidthPacked, - DataGenType::RANDINT4); - quantized_weight.set_constant(true); - quantized_weight.set_int4(true); - - if (debugging()) { - print_valuespec_data(quantized_weight, "weight_tensor"); - } - - // Weight quantization scales (float/half, per-group) - // For group symmetric quantization: [K/group_size, N] - // Each group of input features has scales for all output features - std::vector weight_scales_size = { - config.K / config.group_size, config.N}; - ValueSpec weight_scales( - weight_scales_size, - input_dtype, - storage_type, - utils::kWidthPacked, - DataGenType::RANDOM_SCALES); - weight_scales.set_constant(true); - - // Pre-computed per-group weight sums for zero point adjustment - // This is needed for activation+weight quantized operations - // Size: [K/group_size, N] - one sum per group per output feature - ValueSpec weight_sums( - weight_scales_size, // Same size as weight_scales - vkapi::kInt, - storage_type, - utils::kWidthPacked, - DataGenType::ZEROS); - weight_sums.set_constant(true); - - // Compute weight_sums data based on quantized weights - int64_t num_groups = config.K / config.group_size; - compute_weight_sums_4bit_grouped( - weight_sums, quantized_weight, num_groups, config.N, config.group_size); - - // Group size parameter - ValueSpec group_size_spec(static_cast(config.group_size)); - - // Bias (optional, float/half) - [N] - ValueSpec bias( - {config.N}, // Per output feature - input_dtype, - storage_type, - utils::kWidthPacked, - config.has_bias ? DataGenType::RANDOM : DataGenType::ZEROS); - bias.set_constant(true); - if (!config.has_bias) { - bias.set_none(true); - } - - // Output tensor (float/half) - [M, N] - ValueSpec output( - {config.M, config.N}, - input_dtype, - storage_type, - utils::kWidthPacked, - DataGenType::ZEROS); - - // Loosen tolerances for fp16 activations. The shader accumulates in fp16 - // while the CPU reference accumulates in fp32, so the per-output error - // grows with K. Scale absolute tolerance with K to handle both small - // (K=64) correctness shapes and large (K=14336) Llama shapes; relative - // tolerance covers magnitude scaling. - if (input_dtype == vkapi::kHalf) { - // The shader does fp16 multiplies and (likely) fp16 accumulation, - // while the CPU reference does fp32 arithmetic on values converted - // from fp16. For sums-near-zero (frequent with random +/-10 inputs - // multiplied by INT4 weights in +/-8), per-step rounding in the fp16 - // accumulator can produce absolute errors comparable to the typical - // contribution magnitude. Tolerance is set generously here: the goal - // is catching structural bugs (wrong indexing, wrong dtype, wrong - // scale application -> outputs off by orders of magnitude), not - // certifying bit-exactness against an fp32 reference. The k-scaled - // term grows the bound with accumulation length. - const float k_scaled_abs = 0.1f * std::sqrt(static_cast(config.K)); - test_case.set_abs_tolerance(std::max(1.0f, k_scaled_abs)); - test_case.set_rel_tolerance(0.1f); - } - - // Add all specs to test case based on operator type - if (config.op_name.find("dq8ca") != std::string::npos) { - // For activation+weight quantized linear (linear_dq8ca_q4gsw) - test_case.add_input_spec(input_tensor); - test_case.add_input_spec(input_scale); - test_case.add_input_spec(input_zero_point); - test_case.add_input_spec(quantized_weight); - test_case.add_input_spec(weight_sums); - test_case.add_input_spec(weight_scales); - test_case.add_input_spec(group_size_spec); - test_case.add_input_spec(bias); - test_case.add_output_spec(output); - } else { - // For weight-only quantized linear (linear_q4gsw) - test_case.add_input_spec(input_tensor); - test_case.add_input_spec(quantized_weight); - test_case.add_input_spec(weight_scales); - test_case.add_input_spec(group_size_spec); - test_case.add_input_spec(bias); - test_case.add_output_spec(output); - } - - return test_case; -} - -int64_t quantized_linear_flop_calculator(const TestCase& test_case) { - // Get input and weight dimensions - const auto& input_sizes = test_case.inputs()[0].get_tensor_sizes(); - const auto& output_sizes = test_case.outputs()[0].get_tensor_sizes(); - - int64_t batch_size = input_sizes[0]; - int64_t in_features = input_sizes[1]; - int64_t out_features = output_sizes[1]; - - // Calculate FLOPs for quantized linear operation - // Each output element requires: - // - in_features multiply-accumulate operations - // - Additional operations for quantization/dequantization - int64_t output_elements = batch_size * out_features; - int64_t ops_per_output = in_features; - - // Add quantization overhead (approximate) - // - Unpack 4-bit weight: 1 op per weight element used - // - Dequantize weight: 1 op per weight element used - // - Add bias: 1 op per output element - // - For activation+weight quantized: add input quantization ops - int64_t quantization_ops = ops_per_output * 2 + 1; // Simplified estimate - - int64_t flop = output_elements * (ops_per_output + quantization_ops); - - return flop; -} - -// Create a single test case for the test_fpa_q4gsw_linear.{gemm,gemv} op. -TestCase create_test_case( - const FpaLinearConfig& config, - vkapi::ScalarType dtype, - utils::StorageType storage, - int32_t impl_selector, - bool is_gemv) { - TestCase test_case; - - const int64_t M = config.M; - const int64_t K = config.K; - const int64_t N = config.N; - const int64_t group_size = config.group_size; - - const bool is_performance = - (M > kRefDimSizeLimit || K > kRefDimSizeLimit || N > kRefDimSizeLimit); - const std::string prefix = is_performance ? "PERF" : "ACCU"; - - const std::string dtype_str = dtype_short(dtype); - const std::string shape_str = shape_bracket({M, K}) + "x[" + - std::to_string(N) + "," + std::to_string(K) + "] g" + - std::to_string(group_size); - const std::string storage_str = repr_str(storage, utils::kWidthPacked); - std::string suffix = std::string("[") + (is_gemv ? "gemv" : "gemm") + " s" + - std::to_string(impl_selector) + "]"; - suffix += config.has_bias ? " bias" : " no_bias"; - const std::string test_name = make_test_label( - prefix, dtype_str, dtype_str, shape_str, storage_str, suffix); - test_case.set_name(test_name); - - const std::string op_name = is_gemv ? "test_etvk.test_fpa_q4gsw_linear.gemv" - : "test_etvk.test_fpa_q4gsw_linear.gemm"; - test_case.set_operator_name(op_name); - - // Input: [M, K] - ValueSpec input( - {M, K}, dtype, storage, utils::kWidthPacked, DataGenType::RANDINT); - - // Weight: [N, K/2] uint8 packed 4-bit - ValueSpec weight( - {N, K / 2}, - vkapi::kByte, - storage, - utils::kWidthPacked, - DataGenType::RANDINT4); - weight.set_constant(true); - weight.set_int4(true); - - // Scales: [K/gs, N] matching input dtype (the custom op prepacks scales - // using the input tensor's dtype). - ValueSpec scales( - {K / group_size, N}, - dtype, - storage, - utils::kWidthPacked, - DataGenType::RANDOM_SCALES); - scales.set_constant(true); - - // Group size - ValueSpec gs_spec(static_cast(group_size)); - - // Bias - ValueSpec bias( - {N}, - dtype, - storage, - utils::kWidthPacked, - config.has_bias ? DataGenType::RANDOM : DataGenType::ZEROS); - bias.set_constant(true); - if (!config.has_bias) { - bias.set_none(true); - } - - // impl_selector as int32 - ValueSpec impl_selector_spec(static_cast(impl_selector)); - - // Output: [M, N] - ValueSpec output( - {M, N}, dtype, storage, utils::kWidthPacked, DataGenType::ZEROS); - - // Tolerance: fp16 outputs use relaxed tolerance to account for f16 - // accumulation / rounding. - float base_tol = 0.05f * (static_cast(K) / 64.0f); - float tol = (dtype == vkapi::kHalf) ? (4.0f * base_tol) : base_tol; - test_case.set_abs_tolerance(tol); - - test_case.add_input_spec(input); - test_case.add_input_spec(weight); - test_case.add_input_spec(scales); - test_case.add_input_spec(gs_spec); - test_case.add_input_spec(bias); - test_case.add_input_spec(impl_selector_spec); - test_case.add_output_spec(output); - - return test_case; -} - -// Custom FLOP calculator: 2 * M * K * N for the linear op itself. -int64_t linear_flop_calculator(const TestCase& test_case) { - const auto& input_sizes = test_case.inputs()[0].get_tensor_sizes(); - const auto& output_sizes = test_case.outputs()[0].get_tensor_sizes(); - - int64_t M = input_sizes[0]; - int64_t K = input_sizes[1]; - int64_t N = output_sizes[1]; - return 2 * M * K * N; -} - -bool is_dq8ca(const std::string& op) { - return op.find("dq8ca") != std::string::npos; -} -bool is_4bit(const std::string& op) { - return op.find("q4gsw") != std::string::npos; -} - -// Build one test case for the given op at (storage, half dtype), no bias. -TestCase make_case(const CoopmatLinearConfig& cfg, utils::StorageType storage) { - const vkapi::ScalarType dt = vkapi::kHalf; - TestCase tc; - const std::string storage_str = - (storage == utils::kTexture3D) ? "Texture3D" : "Buffer"; - tc.set_name( - cfg.op_name + "_M" + std::to_string(cfg.M) + "_K" + - std::to_string(cfg.K) + "_N" + std::to_string(cfg.N) + "_" + storage_str); - tc.set_operator_name("et_vk." + cfg.op_name + ".default"); - - ValueSpec input( - {cfg.M, cfg.K}, dt, storage, utils::kWidthPacked, DataGenType::RANDINT); - - // dynamic per-row activation scale/zp (dq8ca only) - ValueSpec input_scale( - {1, cfg.M}, dt, storage, utils::kWidthPacked, DataGenType::RANDOM_SCALES); - input_scale.set_constant(true); - ValueSpec input_zp( - {1, cfg.M}, - vkapi::kChar, - storage, - utils::kWidthPacked, - DataGenType::RANDINT); - input_zp.set_constant(true); - - // weight + scales + sums depend on 4-bit vs 8-bit - const bool four = is_4bit(cfg.op_name); - ValueSpec qweight( - four ? std::vector{cfg.N, cfg.K / 2} - : std::vector{cfg.N, cfg.K}, - four ? vkapi::kByte : vkapi::kChar, - storage, - utils::kWidthPacked, - four ? DataGenType::RANDINT4 : DataGenType::RANDINT8); - qweight.set_constant(true); - if (four) { - qweight.set_int4(true); - } - - std::vector scales_size = four - ? std::vector{cfg.K / cfg.group_size, cfg.N} - : std::vector{cfg.N}; - ValueSpec weight_scales( - scales_size, - dt, - storage, - utils::kWidthPacked, - DataGenType::RANDOM_SCALES); - weight_scales.set_constant(true); - - ValueSpec weight_sums( - scales_size, - vkapi::kInt, - storage, - utils::kWidthPacked, - DataGenType::ZEROS); - weight_sums.set_constant(true); - if (four) { - compute_weight_sums_4bit_grouped( - weight_sums, qweight, cfg.K / cfg.group_size, cfg.N, cfg.group_size); - } else { - compute_weight_sums(weight_sums, qweight, cfg.N, cfg.K); - } - - ValueSpec group_size_spec(static_cast(cfg.group_size)); - - ValueSpec bias({cfg.N}, dt, storage, utils::kWidthPacked, DataGenType::ZEROS); - bias.set_constant(true); - bias.set_none(true); - - ValueSpec output( - {cfg.M, cfg.N}, dt, storage, utils::kWidthPacked, DataGenType::ZEROS); - - // assemble per op signature - if (cfg.op_name == "linear_q4gsw") { - tc.add_input_spec(input); - tc.add_input_spec(qweight); - tc.add_input_spec(weight_scales); - tc.add_input_spec(group_size_spec); - tc.add_input_spec(bias); - } else if (cfg.op_name == "linear_dq8ca_q4gsw") { - tc.add_input_spec(input); - tc.add_input_spec(input_scale); - tc.add_input_spec(input_zp); - tc.add_input_spec(qweight); - tc.add_input_spec(weight_sums); - tc.add_input_spec(weight_scales); - tc.add_input_spec(group_size_spec); - tc.add_input_spec(bias); - } - tc.add_output_spec(output); - return tc; -} - -int64_t flop_calc(const TestCase& tc) { - const auto& in = tc.inputs()[0].get_tensor_sizes(); - const auto& out = tc.outputs()[0].get_tensor_sizes(); - const int64_t M = in[0], K = in[1], N = out[1]; - return 2 * M * N * K; // MAC = 2 flops -} - -namespace { - -// Helper function to unpack 4-bit values from uint8 -std::pair unpack_4bit(uint8_t packed) { - // Extract lower 4 bits and upper 4 bits - int8_t lower = packed & 0x0F; - int8_t upper = (packed >> 4) & 0x0F; - - // Subtract 8 from unpacked 4-bit values - lower -= 8; - upper -= 8; - - return std::make_pair(lower, upper); -} - -// Reference implementation for 4-bit group symmetric weight quantized linear -void linear_q4gsw_reference_impl(TestCase& test_case) { - int32_t idx = 0; - const ValueSpec& input_spec = test_case.inputs()[idx++]; - const ValueSpec& weight_spec = test_case.inputs()[idx++]; - const ValueSpec& weight_scales_spec = test_case.inputs()[idx++]; - const ValueSpec& group_size_spec = test_case.inputs()[idx++]; - const ValueSpec& bias_spec = test_case.inputs()[idx++]; - - // Extract output specification (mutable reference) - ValueSpec& output_spec = test_case.outputs()[0]; - - // Get tensor dimensions - auto input_sizes = input_spec.get_tensor_sizes(); // [batch_size, in_features] - auto weight_sizes = - weight_spec.get_tensor_sizes(); // [in_features, out_features/2] - auto output_sizes = - output_spec.get_tensor_sizes(); // [batch_size, out_features] - - int64_t batch_size = input_sizes[0]; - int64_t in_features = input_sizes[1]; - int64_t out_features = output_sizes[1]; - int64_t group_size = group_size_spec.get_int_value(); - - // Skip for large tensors since computation time will be extremely slow - if (batch_size > kRefDimSizeLimit || in_features > kRefDimSizeLimit || - out_features > kRefDimSizeLimit) { - throw std::invalid_argument( - "One or more dimensions (batch_size, in_features, out_features) exceed the allowed limit for reference implementation."); - } - - if (input_spec.dtype != vkapi::kFloat && input_spec.dtype != vkapi::kHalf) { - throw std::invalid_argument("Unsupported dtype"); - } - - // Get raw data pointers. Activation, weight_scales, and bias may be kFloat - // or kHalf depending on input_dtype; ValueSpec::get_element handles both. - auto& weight_data = weight_spec.get_uint8_data(); - - // Calculate number of output elements - int64_t num_output_elements = batch_size * out_features; - - auto& ref_data = output_spec.get_ref_float_data(); - ref_data.resize(num_output_elements); - - // Perform quantized linear transformation (matrix multiplication) - for (int64_t b = 0; b < batch_size; ++b) { - for (int64_t out_f = 0; out_f < out_features; ++out_f) { - float sum = 0.0f; - - // Matrix multiplication: output[b][out_f] = sum(input[b][in_f] * - // weight[out_f][in_f]) - for (int64_t in_f = 0; in_f < in_features; ++in_f) { - // Get input value - int64_t input_idx = b * in_features + in_f; - float input_val = input_spec.get_element(input_idx); - - // Get weight value and dequantize (4-bit group symmetric quantization) - int64_t group_idx = in_f / group_size; - int64_t scales_idx = group_idx * out_features + out_f; - - // Get packed weight value - weight matrix is [N, K/2] - int64_t weight_idx = (out_f) * (in_features / 2) + (in_f / 2); - uint8_t packed_weight = weight_data[weight_idx]; - - // Unpack 4-bit weight - auto unpacked = unpack_4bit(packed_weight); - int8_t weight_4bit = (in_f % 2 == 0) ? unpacked.first : unpacked.second; - - // Dequantize weight using group symmetric quantization (no zero point) - float weight_scale = weight_scales_spec.get_element(scales_idx); - float dequant_weight = static_cast(weight_4bit) * weight_scale; - - sum += input_val * dequant_weight; - } - - // Add bias and store result - if (!bias_spec.is_none()) { - sum += bias_spec.get_element(out_f); - } - int64_t output_idx = b * out_features + out_f; - ref_data[output_idx] = sum; - } - } -} - -// Reference implementation for activation+weight quantized linear (dq8ca_q4gsw) -void linear_dq8ca_q4gsw_reference_impl(TestCase& test_case) { - // Extract input specifications - int32_t idx = 0; - const ValueSpec& input_spec = test_case.inputs()[idx++]; - const ValueSpec& input_scale_spec = test_case.inputs()[idx++]; - const ValueSpec& input_zeros_spec = test_case.inputs()[idx++]; - const ValueSpec& weight_spec = test_case.inputs()[idx++]; - const ValueSpec& weight_sums_spec = test_case.inputs()[idx++]; - const ValueSpec& weight_scales_spec = test_case.inputs()[idx++]; - const ValueSpec& group_size_spec = test_case.inputs()[idx++]; - const ValueSpec& bias_spec = test_case.inputs()[idx++]; - - // Extract output specification (mutable reference) - ValueSpec& output_spec = test_case.outputs()[0]; - - // Get tensor dimensions - auto input_sizes = input_spec.get_tensor_sizes(); // [batch_size, in_features] - auto weight_sizes = - weight_spec.get_tensor_sizes(); // [out_features, in_features/2] - auto output_sizes = - output_spec.get_tensor_sizes(); // [batch_size, out_features] - - int64_t batch_size = input_sizes[0]; - int64_t in_features = input_sizes[1]; - int64_t out_features = output_sizes[1]; - int64_t group_size = group_size_spec.get_int_value(); - - // Skip for large tensors since computation time will be extremely slow - if (batch_size > kRefDimSizeLimit || in_features > kRefDimSizeLimit || - out_features > kRefDimSizeLimit) { - throw std::invalid_argument( - "One or more dimensions (batch_size, in_features, out_features) exceed the allowed limit for reference implementation."); - } - - // Skip correctness for kHalf: this reference quantizes the activation in fp32 - // (round(x/scale)+zp), but the GPU does the dynamic int8 activation quant in - // fp16, so the round-trip diverges. dq8ca_q4gsw coopmat half-validation needs - // an fp16-accurate reference (Step 2). Perf timings still run. - if (input_spec.dtype == vkapi::kHalf) { - throw std::invalid_argument( - "dq8ca_q4gsw reference skipped for kHalf (fp16 dyn-act quant diverges)"); - } - - if (input_spec.dtype != vkapi::kFloat && input_spec.dtype != vkapi::kHalf) { - throw std::invalid_argument("Unsupported dtype"); - } - - // Activation, input_scale, weight_scales, and bias may be kFloat or kHalf - // depending on input_dtype; ValueSpec::get_element handles both. - auto& input_zero_point_data = input_zeros_spec.get_int8_data(); // Always int8 - - auto& weight_data = weight_spec.get_uint8_data(); - auto& weight_sums_data = weight_sums_spec.get_int32_data(); - (void)weight_sums_data; // Unused for now - - // Calculate number of output elements - int64_t num_output_elements = batch_size * out_features; - - auto& ref_data = output_spec.get_ref_float_data(); - ref_data.resize(num_output_elements); - - // Perform quantized linear transformation (matrix multiplication) with - // integer accumulation - for (int64_t b = 0; b < batch_size; ++b) { - // Use per-input channel scale and zero point - index by batch dimension - float input_scale = input_scale_spec.get_element(b); // {1, M} - int8_t input_zero_point = input_zero_point_data[b]; - - for (int64_t out_f = 0; out_f < out_features; ++out_f) { - // For group symmetric quantization, compute with proper grouping for - // accurate reference - float float_result = 0.0f; - - for (int64_t in_f = 0; in_f < in_features; ++in_f) { - // Get input value and quantize to int8 using per-input channel - // parameters - int64_t input_idx = b * in_features + in_f; - float input_val = input_spec.get_element(input_idx); - - float quant_input_f = - std::round(input_val / input_scale) + input_zero_point; - quant_input_f = std::min(std::max(quant_input_f, -128.0f), 127.0f); - int8_t quantized_input = static_cast(quant_input_f); - - // Get quantized weight and its scale - int64_t weight_idx = out_f * (in_features / 2) + (in_f / 2); - uint8_t packed_weight = weight_data[weight_idx]; - auto unpacked = unpack_4bit(packed_weight); - int8_t quantized_weight = - (in_f % 2 == 0) ? unpacked.first : unpacked.second; - - // Get the appropriate scale for this group - int64_t group_idx = in_f / group_size; - int64_t scales_idx = group_idx * out_features + out_f; - float weight_scale = weight_scales_spec.get_element(scales_idx); - - // Compute the contribution with proper scaling - float contribution = - static_cast(quantized_input - input_zero_point) * - static_cast(quantized_weight) * input_scale * weight_scale; - - float_result += contribution; - } - - // Add bias and store result - if (!bias_spec.is_none()) { - float_result += bias_spec.get_element(out_f); - } - int64_t output_idx = b * out_features + out_f; - ref_data[output_idx] = float_result; - } - } -} - -void linear_dq8ca_q4gsw_weight_only_reference_impl(TestCase& test_case) { - const ValueSpec& input_spec = test_case.inputs()[0]; - const ValueSpec& weight_spec = test_case.inputs()[3]; - const ValueSpec& weight_scales_spec = test_case.inputs()[5]; - const ValueSpec& group_size_spec = test_case.inputs()[6]; - const ValueSpec& bias_spec = test_case.inputs()[7]; - ValueSpec& output_spec = test_case.outputs()[0]; - - const auto input_sizes = input_spec.get_tensor_sizes(); - const auto output_sizes = output_spec.get_tensor_sizes(); - const int64_t batch_size = input_sizes[0]; - const int64_t in_features = input_sizes[1]; - const int64_t out_features = output_sizes[1]; - const int64_t group_size = group_size_spec.get_int_value(); - - if (batch_size > kRefDimSizeLimit || in_features > kRefDimSizeLimit || - out_features > kRefDimSizeLimit) { - throw std::invalid_argument( - "One or more dimensions exceed the allowed limit for reference implementation."); - } - if (input_spec.dtype != vkapi::kFloat && input_spec.dtype != vkapi::kHalf) { - throw std::invalid_argument("Unsupported dtype"); - } - - const auto& weight_data = weight_spec.get_uint8_data(); - auto& ref_data = output_spec.get_ref_float_data(); - ref_data.resize(batch_size * out_features); - - for (int64_t b = 0; b < batch_size; ++b) { - for (int64_t out_f = 0; out_f < out_features; ++out_f) { - float sum = 0.0f; - for (int64_t in_f = 0; in_f < in_features; ++in_f) { - const float input_val = input_spec.get_element(b * in_features + in_f); - const int64_t group_idx = in_f / group_size; - const int64_t scales_idx = group_idx * out_features + out_f; - const int64_t weight_idx = out_f * (in_features / 2) + (in_f / 2); - const auto unpacked = unpack_4bit(weight_data[weight_idx]); - const int8_t weight = - (in_f % 2 == 0) ? unpacked.first : unpacked.second; - sum += input_val * static_cast(weight) * - weight_scales_spec.get_element(scales_idx); - } - if (!bias_spec.is_none()) { - sum += bias_spec.get_element(out_f); - } - ref_data[b * out_features + out_f] = sum; - } - } -} - -} // namespace - -void reference_impl(TestCase& test_case) { - if (test_case.name().find("tiledm2") != std::string::npos) { - linear_dq8ca_q4gsw_weight_only_reference_impl(test_case); - } else if (test_case.operator_name().find("dq8ca") != std::string::npos) { - linear_dq8ca_q4gsw_reference_impl(test_case); - } else { - linear_q4gsw_reference_impl(test_case); - } -} - -namespace { - -// Convert a ValueSpec's input data (float or half) into a flat -// std::vector for use in the reference implementation. -std::vector input_to_float_vec(const ValueSpec& spec) { - if (spec.dtype == vkapi::kFloat) { - return spec.get_float_data(); - } - if (spec.dtype == vkapi::kHalf) { - const auto& half_data = spec.get_half_data(); - std::vector out(half_data.size()); - for (size_t i = 0; i < half_data.size(); ++i) { - out[i] = half_to_float(half_data[i]); - } - return out; - } - throw std::invalid_argument( - "Reference implementation supports only float/half input dtypes."); -} - -// Reference implementation: simple dequant + fp32 GEMM. Only runs for -// small shapes (gate on kRefDimSizeLimit). -void fpa_linear_q4gsw_reference_impl(TestCase& test_case) { - int32_t idx = 0; - const ValueSpec& input_spec = test_case.inputs()[idx++]; - const ValueSpec& weight_spec = test_case.inputs()[idx++]; - const ValueSpec& scales_spec = test_case.inputs()[idx++]; - const ValueSpec& gs_spec = test_case.inputs()[idx++]; - const ValueSpec& bias_spec = test_case.inputs()[idx++]; - // impl_selector is not used in the reference impl - ++idx; - - ValueSpec& output_spec = test_case.outputs()[0]; - - auto input_sizes = input_spec.get_tensor_sizes(); - auto output_sizes = output_spec.get_tensor_sizes(); - - int64_t M = input_sizes[0]; - int64_t K = input_sizes[1]; - int64_t N = output_sizes[1]; - int64_t group_size = gs_spec.get_int_value(); - - if (M > kRefDimSizeLimit || K > kRefDimSizeLimit || N > kRefDimSizeLimit) { - throw std::invalid_argument( - "Dimensions exceed limit for reference implementation."); - } - - std::vector input_data = input_to_float_vec(input_spec); - auto& weight_data = weight_spec.get_uint8_data(); - std::vector scales_data = input_to_float_vec(scales_spec); - std::vector bias_data; - if (!bias_spec.is_none()) { - bias_data = input_to_float_vec(bias_spec); - } - - int64_t num_output_elements = M * N; - auto& ref_data = output_spec.get_ref_float_data(); - ref_data.resize(num_output_elements); - - for (int64_t m = 0; m < M; ++m) { - for (int64_t n = 0; n < N; ++n) { - float sum = 0.0f; - for (int64_t k = 0; k < K; ++k) { - float input_val = input_data[m * K + k]; - - int64_t weight_idx = n * (K / 2) + (k / 2); - uint8_t packed = weight_data[weight_idx]; - int8_t nibble = (k % 2 == 0) - ? static_cast(packed & 0x0F) - 8 - : static_cast((packed >> 4) & 0x0F) - 8; - - int64_t group_idx = k / group_size; - float scale = scales_data[group_idx * N + n]; - - sum += input_val * static_cast(nibble) * scale; - } - if (!bias_spec.is_none()) { - sum += bias_data[n]; - } - ref_data[m * N + n] = sum; - } - } -} - -} // namespace - -void fpa_reference_impl(TestCase& test_case) { - fpa_linear_q4gsw_reference_impl(test_case); -} - -namespace { - -// ---- correctness reference for all four ops; oversized shapes (the perf -// cases) throw -> framework marks them SKIPPED. For dq8ca the activation -// quant round-trip (round(x/scale)+zp) is mirrored in fp32; this is exact -// (not just close) for the coopmat_correctness data, which uses scale=1/16, -// zp=0 and activations that are multiples of 1/16, so fp16-vs-fp32 -// divergence cannot occur. ---- -std::vector as_f(const ValueSpec& s) { - if (s.dtype == vkapi::kFloat) { - return s.get_float_data(); - } - const auto& h = s.get_half_data(); - std::vector o(h.size()); - for (size_t i = 0; i < h.size(); ++i) { - o[i] = half_to_float(h[i]); - } - return o; -} - -} // namespace - -void bench_reference(TestCase& tc) { - const std::string op = tc.operator_name(); - const bool dq8ca = op.find("dq8ca") != std::string::npos; - const bool four = op.find("q4gsw") != std::string::npos; - const ValueSpec& in = tc.inputs()[0]; - ValueSpec& out = tc.outputs()[0]; - const auto is = in.get_tensor_sizes(); - const int64_t M = is[0], K = is[1]; - const int64_t N = out.get_tensor_sizes()[1]; - if (M > 256 || K > 256 || N > 256) { - throw std::invalid_argument("ref: too big"); - } - // input layouts: weight-only = {in, w, w_scales, [group], bias}; - // dq8ca = {in, in_scale, in_zp, w, w_sums, w_scales, [group], bias} - const ValueSpec& w = tc.inputs()[dq8ca ? 3 : 1]; - const ValueSpec& sc = tc.inputs()[dq8ca ? 5 : 2]; - const int64_t group = four ? tc.inputs()[dq8ca ? 6 : 3].get_int_value() : K; - const ValueSpec& bias = tc.inputs()[dq8ca ? (four ? 7 : 6) : (four ? 4 : 3)]; - const bool has_bias = !bias.is_none(); - - const std::vector inf = as_f(in); - const std::vector scf = as_f(sc); - const std::vector bf = has_bias ? as_f(bias) : std::vector(); - const std::vector in_scale = - dq8ca ? as_f(tc.inputs()[1]) : std::vector(); - const std::vector& in_zp = - dq8ca ? tc.inputs()[2].get_int8_data() : std::vector(); - const std::vector& w4 = - four ? w.get_uint8_data() : std::vector(); // [N, K/2] nibbles - const std::vector& w8 = - four ? std::vector() : w.get_int8_data(); // [N, K] - - auto& ref = out.get_ref_float_data(); - ref.resize(M * N); - for (int64_t m = 0; m < M; ++m) { - const float s_in = dq8ca ? in_scale[m] : 1.0f; - const int zp = dq8ca ? int(in_zp[m]) : 0; - for (int64_t n = 0; n < N; ++n) { - float acc = 0.0f; - for (int64_t k = 0; k < K; ++k) { - float a = inf[m * K + k]; - if (dq8ca) { - float q = std::round(a / s_in) + float(zp); - q = std::min(std::max(q, -128.0f), 127.0f); - a = q - float(zp); - } - int wv; - if (four) { - const uint8_t byte = w4[n * (K / 2) + k / 2]; - const int nib = (k & 1) ? ((byte >> 4) & 0xF) : (byte & 0xF); - wv = nib - 8; - } else { - wv = w8[n * K + k]; - } - const float w_scale = four ? scf[(k / group) * N + n] : scf[n]; - acc += a * float(wv) * w_scale; - } - float r = dq8ca ? acc * s_in : acc; - if (has_bias) { - r += bf[n]; - } - ref[m * N + n] = r; - } - } -} - -} // namespace q4gsw_linear -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q4gsw_linear/q4gsw_linear.h b/backends/vulkan/test/ops/benchmark/cases/q4gsw_linear/q4gsw_linear.h deleted file mode 100644 index 5ee95032801..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q4gsw_linear/q4gsw_linear.h +++ /dev/null @@ -1,130 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#pragma once - -#include -#include - -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q4gsw_linear { - -constexpr int64_t kRefDimSizeLimit = 300; - -// Linear configuration struct -struct LinearConfig { - int64_t M; // Batch size / number of rows in input - int64_t K; // Input features / columns in input, rows in weight - int64_t N; // Output features / columns in weight - int64_t group_size; // Number of input channels per quantization group - bool has_bias = false; - std::string test_case_name = "placeholder"; - std::string op_name = "linear_dq8ca_q4gsw"; -}; - -TestCase create_test_case_from_config( - const LinearConfig& config, - utils::StorageType storage_type, - vkapi::ScalarType input_dtype); - -void reference_impl(TestCase& test_case); - -int64_t quantized_linear_flop_calculator(const TestCase& test_case); - -// FPA Q4GSW Linear A/B benchmark binary. -// -// Each generated test case has an `impl_selector` arg routed to the test -// op `test_etvk.test_fpa_q4gsw_linear.{gemm,gemv}` in TestFpaQ4gswLinear.cpp: -// -// GEMM (is_gemv=false): -// 0 -> PROD (et_vk.q4gsw_linear.default; dtype-based -// picker) 1 -> GEMM_W_4X8 (forced non-tin GEMM, nc buffer -// weight) 2 -> GEMM_TIN_W_4X8 (forced tin GEMM, nc buffer -// weight) 3 -> LEGACY (et_vk.linear_q4gsw.default -// legacy shaders) -// -// GEMV (is_gemv=true): -// 0 -> PROD (et_vk.q4gsw_linear.default; -// dtype-based picker) 1 -> GEMV_W_4X8 (forced gemv with -// subgroup broadcast) 2 -> GEMV_W_4X8_NOSG (forced gemv -// without subgroup broadcast) 3 -> LEGACY (et_vk.linear_q4gsw.default -// legacy shaders) 13 -> GEMV_COOP_W_4X8_NC_BUFFER (coop GEMV reusing the -// production -// nc-buffer prepack — same payload as -// W_4X8 GEMM/TIN GEMM/sg-GEMV; -// == g1w64 decomposition) -// 14 -> GEMV_COOP_..._G1W64 (force NUM_GROUPS=1, -// WORKERS_PER_GROUP=64) 15 -> GEMV_COOP_..._G4W16 (force -// NUM_GROUPS=4, WORKERS_PER_GROUP=16) 16 -> GEMV_COOP_..._G8W8 (force -// NUM_GROUPS=8, WORKERS_PER_GROUP=8) -// -// Selectors 14-16 pin the coop nc-buffer GEMV to an explicit reduction -// decomposition regardless of N. The production picker (pick_coop_variant_for_N -// in Q4gswLinear.cpp) only chooses g4w16 / g8w8 at PERF-sized N where the -// reference impl is skipped; these forced selectors give g4w16 / g8w8 numeric -// (ACCU) coverage at small N. Production picker behavior is unchanged. -// -// Selector 3 (LEGACY) is the in-prod q4gsw linear path. It uses a different -// prepack (pack_q4_linear_weight) and shader family -// (linear_q4gsw_tiled_* / linear_q4gsw_coop_*); the framework's per-shader -// timing breakdown will pick those up automatically. - -// Linear configuration struct. -struct FpaLinearConfig { - int64_t M; - int64_t K; - int64_t N; - int64_t group_size; - bool has_bias = false; -}; - -TestCase create_test_case( - const FpaLinearConfig& config, - vkapi::ScalarType dtype, - utils::StorageType storage, - int32_t impl_selector, - bool is_gemv); - -void fpa_reference_impl(TestCase& test_case); - -int64_t linear_flop_calculator(const TestCase& test_case); - -// Consolidated coopmat-vs-tiled microbenchmark for the two int4-quantized -// linear types at Llama 3.1 8B prefill shapes: -// 4w = linear_q4gsw (weight-only int4) -// 8da4w = linear_dq8ca_q4gsw (dyn-act int8 x int4 weight) -// -// Baseline (tiled) is selected by Texture3D+Half output storage; coopmat is -// selected by Buffer+Half (the runtime gate in QuantizedLinear.cpp picks the -// _coopmat shader when M%64==0, N%64==0, K%32==0, subgroup==64). The CPU -// reference only runs for the small shapes in coopmat_correctness. - -struct CoopmatLinearConfig { - int64_t M; - int64_t K; - int64_t N; - int64_t group_size; // only meaningful for 4-bit - std::string op_name; -}; - -bool is_dq8ca(const std::string& op); - -bool is_4bit(const std::string& op); - -TestCase make_case(const CoopmatLinearConfig& cfg, utils::StorageType storage); - -void bench_reference(TestCase& tc); - -int64_t flop_calc(const TestCase& tc); - -} // namespace q4gsw_linear -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q4gsw_linear/test_sets/coopmat_sets.cpp b/backends/vulkan/test/ops/benchmark/cases/q4gsw_linear/test_sets/coopmat_sets.cpp deleted file mode 100644 index 826bdde55c5..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q4gsw_linear/test_sets/coopmat_sets.cpp +++ /dev/null @@ -1,154 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include -#include -#include -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q4gsw_linear { - -namespace { - -// POSITIVE well-conditioned data (no fp16 cancellation): activations are -// multiples of 1/16 in [0.5,1.375]; int4 nibbles in {9..14} (-> weight +1..+6) -// / int8 weights in {1..6}. For dq8ca the per-row activation scale is forced to -// 1/16 with zp=0 so the dynamic int8 quant round-trip is EXACT in both fp16 and -// fp32 (quantized values 8..22) and the fp32 reference is valid. fp16~=fp32 -// throughout, so a tight tolerance validates shader structure (catches -// zero-subtile bugs) while ignoring benign fp16 noise. -void set_well_conditioned_data(TestCase& t, const CoopmatLinearConfig& cfg) { - const bool dq = is_dq8ca(cfg.op_name); - const bool four = is_4bit(cfg.op_name); - auto& hin = t.inputs()[0].get_half_data(); - for (size_t i = 0; i < hin.size(); ++i) { - hin[i] = float_to_half(0.5f + 0.125f * float(i % 8)); - } - const size_t w_idx = dq ? 3 : 1; - if (four) { - auto& wq = t.inputs()[w_idx].get_uint8_data(); - const std::array kPos = {0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE}; - for (size_t i = 0; i < wq.size(); ++i) { - wq[i] = kPos[i % 6]; - } - } else { - auto& wq = t.inputs()[w_idx].get_int8_data(); - for (size_t i = 0; i < wq.size(); ++i) { - wq[i] = int8_t(1 + (i % 6)); - } - } - if (dq) { - auto& hs = t.inputs()[1].get_half_data(); - std::fill(hs.begin(), hs.end(), float_to_half(0.0625f)); - auto& zp = t.inputs()[2].get_int8_data(); - std::fill(zp.begin(), zp.end(), int8_t(0)); - // weights were overwritten above -> recompute the sums - if (four) { - compute_weight_sums_4bit_grouped( - t.inputs()[4], - t.inputs()[w_idx], - cfg.K / cfg.group_size, - cfg.N, - cfg.group_size); - } else { - compute_weight_sums(t.inputs()[4], t.inputs()[w_idx], cfg.N, cfg.K); - } - } - t.set_abs_tolerance(0.5f); - t.set_rel_tolerance(0.05f); -} - -std::vector generate_coopmat_test_cases( - const std::vector& shapes, - const std::vector& ops, - const std::vector& storage_types, - bool well_conditioned_data) { - std::vector cases; - for (const auto& op : ops) { - for (const auto& shape : shapes) { - CoopmatLinearConfig cfg{shape.M, shape.K, shape.N, shape.group_size, op}; - for (auto st : storage_types) { - TestCase t = make_case(cfg, st); - if (well_conditioned_data) { - set_well_conditioned_data(t, cfg); - } - cases.push_back(std::move(t)); - } - } - } - return cases; -} - -} // namespace - -REGISTER_TEST_CASE_SET("q4gsw_linear", "coopmat_correctness") { - // Correctness: small aligned {64,128,64} cases for ALL FOUR ops; the buffer - // case fires the coopmat shader, validated by bench_reference (the - // performance cases are skipped by it). Texture3D = tiled, Buffer = coopmat. - // Shapes align to BOTH coopmat geometries (64x64x32 legacy, 128x128x16 - // double-buffered); the second shape dispatches a multi-workgroup grid for - // both, covering the gl_WorkGroupID-derived tile offsets in the store - // address math. - return { - generate_coopmat_test_cases( - { - {64, 128, 64, 64, ""}, - {128, 256, 128, 64, ""}, - {128, 128, 128, 64, ""}, - {256, 256, 256, 64, ""}, - // Discriminators for the tiled-texture cube-shape failure: - {128, 128, 256, 64, ""}, // M == K only - {256, 128, 128, 64, ""}, // K == N only - {64, 128, 256, 64, ""}, // K > M, K < N - {256, 128, 64, 64, ""}, // K < M, K > N - }, - /*ops=*/{"linear_q4gsw", "linear_dq8ca_q4gsw"}, - /*storage_types=*/{utils::kTexture3D, utils::kBuffer}, - /*well_conditioned_data=*/true), - bench_reference, - flop_calc, - /*warmup_runs=*/3, - /*benchmark_runs=*/5}; -} - -namespace { - -constexpr int64_t kM = 1024; -constexpr int64_t kGroup = 128; - -} // namespace - -REGISTER_TEST_CASE_SET("q4gsw_linear", "coopmat_performance") { - return { - generate_coopmat_test_cases( - { - // Llama 3.1 8B linear weight shapes (K,N) at prefill M (multiple - // of 64 so coopmat fires). - {kM, 4096, 4096, kGroup, ""}, // q_proj / o_proj - {kM, 4096, 1024, kGroup, ""}, // k_proj / v_proj (GQA) - {kM, 4096, 14336, kGroup, ""}, // gate_proj / up_proj - {kM, 14336, 4096, kGroup, ""}, // down_proj - }, - /*ops=*/{"linear_q4gsw", "linear_dq8ca_q4gsw"}, - // Texture3D selects the tiled shader, Buffer the coopmat shader. - /*storage_types=*/{utils::kTexture3D, utils::kBuffer}, - /*well_conditioned_data=*/false), - bench_reference, - flop_calc, - /*warmup_runs=*/3, - /*benchmark_runs=*/5}; -} - -} // namespace q4gsw_linear -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q4gsw_linear/test_sets/debug_set.cpp b/backends/vulkan/test/ops/benchmark/cases/q4gsw_linear/test_sets/debug_set.cpp deleted file mode 100644 index 64e1d6999c7..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q4gsw_linear/test_sets/debug_set.cpp +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q4gsw_linear { - -namespace { - -std::vector generate_debug_test_cases( - const LinearConfig& config, - const std::vector& storage_types, - const std::vector& input_dtypes) { - std::vector test_cases; - - for (const auto& storage_type : storage_types) { - for (const auto& input_dtype : input_dtypes) { - test_cases.push_back( - create_test_case_from_config(config, storage_type, input_dtype)); - } - } - - return test_cases; -} - -} // namespace - -REGISTER_TEST_CASE_SET("q4gsw_linear", "debug") { - // Single simple configuration for debugging - return { - generate_debug_test_cases( - { - 8, // Batch size - 16, // Input features - 16, // Output features - 8, // Group size - true, // has_bias - "simple", // test_case_name - "linear_dq8ca_q4gsw", // op_name - }, - /*storage_types=*/{utils::kTexture3D, utils::kBuffer}, - /*input_dtypes=*/{vkapi::kFloat}), - reference_impl, - quantized_linear_flop_calculator}; -} - -} // namespace q4gsw_linear -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q4gsw_linear/test_sets/fpa_sets.cpp b/backends/vulkan/test/ops/benchmark/cases/q4gsw_linear/test_sets/fpa_sets.cpp deleted file mode 100644 index 9e7902a6008..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q4gsw_linear/test_sets/fpa_sets.cpp +++ /dev/null @@ -1,264 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q4gsw_linear { - -namespace { - -// One case per M x (K, N) x dtype x storage x impl_selector, in that nesting -// order. -struct FpaSweep { - std::vector Ms; - std::vector> nk_shapes; // (K, N) - std::vector dtypes; - std::vector storages; - std::vector impl_selectors; - bool is_gemv; -}; - -std::vector generate_fpa_test_cases( - int64_t group_size, - const std::vector& sweeps) { - std::vector test_cases; - for (const auto& sweep : sweeps) { - for (int64_t M : sweep.Ms) { - for (const auto& shape : sweep.nk_shapes) { - const int64_t K = shape.first; - const int64_t N = shape.second; - FpaLinearConfig cfg{M, K, N, group_size}; - for (auto dtype : sweep.dtypes) { - for (auto storage : sweep.storages) { - for (int32_t selector : sweep.impl_selectors) { - test_cases.push_back(create_test_case( - cfg, dtype, storage, selector, sweep.is_gemv)); - } - } - } - } - } - } - return test_cases; -} - -const std::vector kDtypes = {vkapi::kFloat, vkapi::kHalf}; -const std::vector kStorages = { - utils::kBuffer, - utils::kTexture3D}; - -// ACCU correctness shapes (under kRefDimSizeLimit=300). Exercise selectors -// PROD (0) and forced nosg (2). Forced sg (1) is intentionally skipped: -// sg requires subgroupSize==64 and produces incorrect results on Mali -// (subgroupSize==16); on those devices the PROD picker correctly routes -// to the nosg variant. N must be a multiple of 128 (= 2 * LWG.x) so the -// GEMV shader has no early-exit threads in any workgroup. -const std::vector> kAccuShapes = { - // (K, N) - {128, 128}, - {256, 256}, -}; -// Selector 13 (nc Buffer, reuses production prepack) included for ACCU -// coverage of the coop nc weight-binding variant. -const std::vector kAccuSelectors = {0, 2, 13}; - -// Forced coop-reduction-decomposition ACCU coverage (selectors 14/15/16 = -// g1w64 / g4w16 / g8w8). The production picker (pick_coop_variant_for_N) -// only selects g4w16 (10244096) at PERF-sized N, where -// every dim exceeds kRefDimSizeLimit=300 so the reference impl is skipped and -// those reduction decompositions get zero numeric validation. These cases -// pin each decomposition regardless of N at small shapes (all dims <= 300) -// so the reference runs and proves g4w16 / g8w8 compute the SAME result as -// the reference — the M=1 decode path actually shipped for Qwen3 / Llama. -// -// Each WG of variant gN produces N*8 outputs (g1w64 -> 8, g4w16 -> 32, -// g8w8 -> 64), so the N values below tile cleanly into all three: N=128 -// (16 / 4 / 2 WGs) and N=256 (32 / 8 / 4 WGs). The shader also handles a -// ragged final WG, but clean tiles keep the test intent unambiguous. K is -// swept over {64, 128, 256} (all multiples of group_size=32 and <= 300) so -// the K-loop reduction is exercised across short and longer accumulations. -const std::vector> kCoopForcedAccuShapes = { - // (K, N) — all dims <= 300 so linear_q4gsw_reference_impl runs. - {64, 128}, - {128, 256}, - {256, 128}, -}; -// 14 -> g1w64, 15 -> g4w16, 16 -> g8w8. g1w64 included for symmetry. -const std::vector kCoopForcedSelectors = {14, 15, 16}; - -// Non-aligned-N coverage for the W_4X8 GEMM path. The fp32 GEMM issues a -// 16B ivec4 weight load that spans two consecutive (k4, n4) ivec2 tiles -// along N, so N4 must be even (== N a multiple of 8) at the buffer-stride -// level. The prepack pads the weight buffer's row stride to next-even N4 -// and fills the OOB tiles with bias-zero nibbles; these accuracy cases -// exercise that padding path on shapes with N % 8 != 0. Only the new W_4X8 -// family is tested (selectors 0, 1, 2, 5, 6) — selector 3 (LEGACY) uses a -// different prepack and supports arbitrary N. -const std::vector> kNonAlignedNkShapes = { - // (K, N) — K kept under kRefDimSizeLimit so reference impl runs. - {128, 12}, - {128, 20}, -}; -const std::vector kNonAlignedSelectors = {0, 1, 2}; - -} // namespace - -REGISTER_TEST_CASE_SET("q4gsw_linear", "fpa_correctness") { - return { - generate_fpa_test_cases( - /*group_size=*/32, - { - // GEMV - {{1}, - kAccuShapes, - kDtypes, - kStorages, - kAccuSelectors, - /*is_gemv=*/true}, - {{1}, - kCoopForcedAccuShapes, - kDtypes, - kStorages, - kCoopForcedSelectors, - /*is_gemv=*/true}, - // GEMM - {{32}, - kNonAlignedNkShapes, - kDtypes, - kStorages, - kNonAlignedSelectors, - /*is_gemv=*/false}, - // Small ACCU shape (M=32, K=128, N=128) under kRefDimSizeLimit so - // the reference impl runs. Sanity-checks GEMM correctness during - // iteration. - {{32}, - {{128, 128}}, - kDtypes, - {utils::kTexture3D}, - /*impl_selectors=*/{0}, - /*is_gemv=*/false}, - // M-tail ACCU shapes. These exercise final partial GEMM tiles for - // both the fp32 direct-input path (tile height 4) and the fp16 - // TIN path (tile height 8). - {{31, 33}, - {{128, 128}}, - kDtypes, - {utils::kTexture3D}, - /*impl_selectors=*/{0}, - /*is_gemv=*/false}, - }), - fpa_reference_impl, - linear_flop_calculator, - /*warmup_runs=*/3, - /*benchmark_runs=*/10}; -} - -namespace { - -// Canonical N/K shapes for LLM hidden-size sweeps. -const std::vector> kNkShapes = { - // (K, N) - {1024, 2048}, - {4096, 4096}, - // {4096, 14336}, // Large-N case can make the full benchmark binary - // unstable. -}; - -// GEMV PERF selectors: PROD (0), forced sg (1), forced nosg (2), LEGACY (3), -// nc-Buffer coop (13, reuses production prepack — single-format -// prefill+decode). -const std::vector kGemvPerfSelectors = {0, 1, 2, 3, 13}; - -// LLM-decode-shape PERF cells (M=1 GEMV, group_size=32). Mirrors the actual -// per-layer linear shapes seen during decode profiling on Llama 3.2 1B and -// Qwen3 0.6B; the original Phase 2 corpus (1024/2048/4096 x 2048/4096/11008) -// under-samples these and missed the regression where sg-GEMV (selector 1) -// is 15-22% slower per dispatch than LEGACY coop (selector 3) on Adreno 750. -// -// All N values here are multiples of 128 (= 2 * LWG.x for the GEMV shader), -// so the GEMV shader has no early-exit threads. N=512 is the Llama 3.2 1B -// k_proj/v_proj projection (GQA) and is a multiple of 4 (the prepack -// requirement: prepack_q4_w_4x8_nc_buffer enforces N % 4 == 0). -// -// Default storage is fp16 + Tex3D - that's the actual decode config and the -// shape combo where the regression was observed. We additionally exercise -// K=2048,N=2048 under fp32 + Tex3D and fp32 + Buffer to confirm the -// regression isn't fp16-Tex3D-specific. All four selectors (PROD, sg, nosg, -// LEGACY) are exercised. -const std::vector> kLlmGemvShapes = { - // (K, N) - Llama 3.2 1B - {2048, 512}, // k_proj / v_proj (GQA) - {2048, 2048}, // q_proj - {2048, 8192}, // gate_proj / up_proj - {8192, 2048}, // down_proj - // (K, N) - Qwen3 0.6B - {1024, 1024}, // k_proj / v_proj - {1024, 2048}, // q_proj (also overlaps with original corpus) - {1024, 3072}, // gate_proj / up_proj - {3072, 1024}, // down_proj -}; - -// Selectors exercised in the GEMM PERF/ACCU sweep: PROD (0), forced non-tin -// / tin GEMM (1, 2), legacy (3). -// -// Selector 3 is the legacy in-prod q4gsw linear path -// (et_vk.linear_q4gsw.default) registered in QuantizedLinear.cpp. -const std::vector kGemmSelectors = {0, 1, 2, 3}; - -} // namespace - -REGISTER_TEST_CASE_SET("q4gsw_linear", "fpa_performance") { - return { - generate_fpa_test_cases( - /*group_size=*/32, - { - // GEMV sweep: M = 1 x N/K shapes x dtype x storage x - // impl_selector. - {{1}, - kNkShapes, - kDtypes, - kStorages, - kGemvPerfSelectors, - /*is_gemv=*/true}, - {{1}, - kLlmGemvShapes, - {vkapi::kHalf}, - {utils::kTexture3D}, - kGemvPerfSelectors, - /*is_gemv=*/true}, - // Diversity sanity check: K=2048,N=2048 under fp32 + {Tex3D, - // Buffer} to confirm the regression isn't fp16-Tex3D-specific. - {{1}, - {{2048, 2048}}, - {vkapi::kFloat}, - {utils::kTexture3D, utils::kBuffer}, - kGemvPerfSelectors, - /*is_gemv=*/true}, - // GEMM sweep: M in {32, 128, 256} x N/K shapes x dtype x storage - // x impl_selector. - {{32, 128, 256}, - kNkShapes, - kDtypes, - kStorages, - kGemmSelectors, - /*is_gemv=*/false}, - }), - fpa_reference_impl, - linear_flop_calculator, - /*warmup_runs=*/3, - /*benchmark_runs=*/10}; -} - -} // namespace q4gsw_linear -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q4gsw_linear/test_sets/q4gsw_linear_sets.cpp b/backends/vulkan/test/ops/benchmark/cases/q4gsw_linear/test_sets/q4gsw_linear_sets.cpp deleted file mode 100644 index be0e57dbe42..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q4gsw_linear/test_sets/q4gsw_linear_sets.cpp +++ /dev/null @@ -1,164 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q4gsw_linear { - -namespace { - -std::vector generate_test_cases( - const std::vector& configs, - const std::vector& storage_types, - const std::vector& input_dtypes, - const std::vector& ops, - bool include_narrow_tile_cases = false) { - std::vector test_cases; - - const bool supports_int8_dot_product = - vkcompute::api::context()->adapter_ptr()->supports_int8_dot_product(); - - for (auto config : configs) { - std::string prefix = - (config.M < kRefDimSizeLimit && config.K < kRefDimSizeLimit && - config.N < kRefDimSizeLimit) - ? "correctness_" - : "performance_"; - std::string generated_test_case_name = prefix + std::to_string(config.M) + - "_" + std::to_string(config.K) + "_" + std::to_string(config.N) + "_g" + - std::to_string(config.group_size); - if (!config.has_bias) { - generated_test_case_name += "_no_bias"; - } - - config.test_case_name = generated_test_case_name; - - for (const auto& storage_type : storage_types) { - for (const auto& input_dtype : input_dtypes) { - for (const auto& op : ops) { - if (is_dq8ca(op) && !supports_int8_dot_product) { - continue; - } - LinearConfig op_config = config; - op_config.op_name = op; - test_cases.push_back(create_test_case_from_config( - op_config, storage_type, input_dtype)); - } - } - } - } - - if (include_narrow_tile_cases) { - for (int64_t M : {2, 3, 5}) { - for (bool has_bias : {true, false}) { - LinearConfig config; - config.M = M; - config.K = 64; - config.N = 32; - config.group_size = 32; - config.has_bias = has_bias; - config.op_name = "linear_dq8ca_q4gsw"; - config.test_case_name = "correctness_tiledm2_M" + std::to_string(M) + - (has_bias ? "" : "_no_bias"); - - for (const auto& storage_type : storage_types) { - for (const auto& input_dtype : input_dtypes) { - TestCase test_case = - create_test_case_from_config(config, storage_type, input_dtype); - test_case.set_name(test_case.name() + " [tiledm2]"); - test_case.set_force_narrow_int4_tile(true); - test_cases.push_back(test_case); - } - } - } - } - } - - return test_cases; -} - -} // namespace - -REGISTER_TEST_CASE_SET("q4gsw_linear", "correctness") { - return { - generate_test_cases( - { - // // Gemv test cases - // {1, 128, 64, 32}, - // {1, 256, 128, 64}, - // Gemm - {4, 64, 32, 16}, - {4, 128, 64, 32}, - {4, 256, 128, 64}, - {32, 64, 32, 16}, - {32, 128, 64, 32}, - {32, 256, 128, 64}, - {256, 128, 128, 32}, // M=256 > K=128; all dims < kRefDimSizeLimit - - // Coopmat-eligible correctness shapes (M%64==0, N%64==0, - // K%32==0, group_size%32==0). The Buffer+Half variant fires - // linear_q4gsw_coopmat / linear_dq8ca_q4gsw_coopmat and is - // validated against the CPU reference. - {64, 64, 64, 64}, - {64, 128, 64, 64}, - {64, 256, 128, 128}, - // With bias - {4, 64, 32, 16, true}, - {4, 128, 64, 32, true}, - {32, 128, 64, 32, true}, - // NOTE: coopmat correctness coverage is NOT in this list. The - // coopmat dispatch gate requires M%64==0, N%64==0, K%32==0; the - // smallest qualifying shape (M=64, K=64, N=64) produces enough - // cancellation outputs that fp16 accumulation drift exceeds any - // reasonable tolerance against the fp32 reference. Validating - // the coopmat shader needs a different strategy (e.g. - // positive-only inputs, or simulating fp16 accumulation in the - // reference). - }, - /*storage_types=*/{utils::kTexture3D, utils::kBuffer}, - // Both fp32 and fp16 activations, so the test covers the _float and - // _half SPIR-V variants of each linear shader. Llama-on-Vulkan - // exports run with backend.vulkan.force_fp16=True, so the _half - // variants are the ones we actually hit in production. - /*input_dtypes=*/{vkapi::kFloat, vkapi::kHalf}, - // Activation+weight quantized and weight-only quantized. - /*ops=*/{"linear_dq8ca_q4gsw", "linear_q4gsw"}, - /*include_narrow_tile_cases=*/true), - reference_impl, - quantized_linear_flop_calculator}; -} - -REGISTER_TEST_CASE_SET("q4gsw_linear", "performance") { - return { - generate_test_cases( - { - // A couple of representative performance shapes - // (coopmat-eligible, M % 64 == 0). The full Llama 3.1 8B prefill - // sweep lived here during the study; trimmed to keep this a fast - // unit test. - {128, 2048, 2048, 128}, - {1024, 4096, 4096, 128}, - }, - /*storage_types=*/{utils::kTexture3D, utils::kBuffer}, - // fp16 is the production path (backend.vulkan.force_fp16=True); fp32 - // covers the _float shader variants. - /*input_dtypes=*/{vkapi::kFloat, vkapi::kHalf}, - // Activation+weight quantized and weight-only quantized. - /*ops=*/{"linear_dq8ca_q4gsw", "linear_q4gsw"}), - reference_impl, - quantized_linear_flop_calculator}; -} - -} // namespace q4gsw_linear -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8csw_conv2d/q8csw_conv2d.cpp b/backends/vulkan/test/ops/benchmark/cases/q8csw_conv2d/q8csw_conv2d.cpp deleted file mode 100644 index 8af65739d8e..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8csw_conv2d/q8csw_conv2d.cpp +++ /dev/null @@ -1,534 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include -#include -#include -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8csw_conv2d { - -// Utility function to create a test case from a Conv2dConfig -TestCase create_test_case_from_config( - const Conv2dConfig& config, - utils::StorageType storage_type, - vkapi::ScalarType input_dtype) { - TestCase test_case; - - // Create a descriptive name for the test case - bool is_perf = config.channels.out > kRefDimSizeLimit || - config.channels.in > kRefDimSizeLimit || - config.input_size.h > kRefDimSizeLimit || - config.input_size.w > kRefDimSizeLimit; - std::string prefix = is_perf ? "PERF" : "ACCU"; - std::string dtype_str = dtype_short(input_dtype); - std::string in_shape = "[1," + std::to_string(config.channels.in) + "," + - std::to_string(config.input_size.h) + "," + - std::to_string(config.input_size.w) + "]"; - std::string weight_shape = "[" + std::to_string(config.channels.out) + "," + - std::to_string(config.channels.in / config.groups) + "," + - std::to_string(config.kernel.h) + "," + std::to_string(config.kernel.w) + - "]"; - std::string shape_str = in_shape + "x" + weight_shape + " s" + - std::to_string(config.stride.h) + " p" + - std::to_string(config.padding.h) + " d" + - std::to_string(config.dilation.h) + " g" + std::to_string(config.groups); - std::string storage_str = repr_str(storage_type, utils::kChannelsPacked); - std::string suffix = "[" + config.op_name + "]"; - std::string test_name = make_test_label( - prefix, dtype_str, dtype_str, shape_str, storage_str, suffix); - test_case.set_name(test_name); - - // Set the operator name for the test case - std::string operator_name = "et_vk." + config.op_name + ".default"; - test_case.set_operator_name(operator_name); - - // Calculate output dimensions - int64_t H_out = config.get_output_height(); - int64_t W_out = config.get_output_width(); - - // Input tensor (float/half) - [1, C_in, H_in, W_in] (batch size always 1) - std::vector input_size = { - 1, config.channels.in, config.input_size.h, config.input_size.w}; - - ValueSpec input_tensor( - input_size, - input_dtype, - storage_type, - utils::kChannelsPacked, - DataGenType::RANDOM); - - if (debugging()) { - print_valuespec_data(input_tensor, "input_tensor"); - } - - float input_scale_val = 0.07f; - ValueSpec input_scale(input_scale_val); - - int32_t input_zero_point_val = -3; - ValueSpec input_zero_point(input_zero_point_val); - - // Quantized weight tensor (int8) - [C_out, C_in_per_group * K_h * K_w] - // Memory layout: height, width, then channels - in_c is innermost (stride 1) - // in the second dimension - const int64_t in_channels_per_group = config.channels.in / config.groups; - const int64_t in_features = utils::align_up_4( - in_channels_per_group * config.kernel.h * config.kernel.w); - std::vector weight_size = {config.channels.out, in_features}; - ValueSpec quantized_weight( - weight_size, - vkapi::kChar, // int8 for quantized weights - storage_type, - utils::kWidthPacked, - DataGenType::RANDINT8); - quantized_weight.set_constant(true); - - if (debugging()) { - print_valuespec_data(quantized_weight, "weight_tensor"); - } - - // Weight quantization scales (float/half, per-channel) - ValueSpec weight_scales( - {config.channels.out}, // Per output channel - input_dtype, - storage_type, - utils::kWidthPacked, - DataGenType::RANDOM_SCALES); - weight_scales.set_constant(true); - - ValueSpec weight_sums( - {config.channels.out}, // Per output channel - vkapi::kInt, - storage_type, - utils::kWidthPacked, - DataGenType::ZEROS); - weight_sums.set_constant(true); - - // Compute weight_sums data based on quantized weights - compute_weight_sums( - weight_sums, quantized_weight, config.channels.out, in_features); - - // Bias (optional, float/half) - [C_out] - ValueSpec bias( - {config.channels.out}, // Per output channel - input_dtype, - storage_type, - utils::kWidthPacked, - DataGenType::RANDOM); - bias.set_constant(true); - - // Stride and padding parameters - ValueSpec stride({config.stride.h, config.stride.w}); - ValueSpec padding({config.padding.h, config.padding.w}); - - // Dilation and groups parameters - ValueSpec dilation({config.dilation.h, config.dilation.w}); - ValueSpec groups(config.groups); - - // Kernel size parameters - ValueSpec kernel_size({config.kernel.h, config.kernel.w}); - - // Output tensor (float/half) - [1, C_out, H_out, W_out] (batch size always 1) - ValueSpec output( - {1, config.channels.out, H_out, W_out}, - input_dtype, - storage_type, - utils::kChannelsPacked, - DataGenType::ZEROS); - - // Add all specs to test case - if (config.op_name.find("q8ta") != std::string::npos) { - test_case.add_input_spec(input_tensor); - test_case.add_input_spec(input_scale); - test_case.add_input_spec(input_zero_point); - test_case.add_input_spec(quantized_weight); - test_case.add_input_spec(weight_sums); - test_case.add_input_spec(weight_scales); - test_case.add_input_spec(bias); - test_case.add_input_spec(kernel_size); - test_case.add_input_spec(stride); - test_case.add_input_spec(padding); - test_case.add_input_spec(dilation); - test_case.add_input_spec(groups); - } else { - test_case.add_input_spec(input_tensor); - test_case.add_input_spec(quantized_weight); - test_case.add_input_spec(weight_scales); - test_case.add_input_spec(bias); - test_case.add_input_spec(kernel_size); - test_case.add_input_spec(stride); - test_case.add_input_spec(padding); - test_case.add_input_spec(dilation); - test_case.add_input_spec(groups); - } - - test_case.add_output_spec(output); - - return test_case; -} - -// Custom FLOP calculator for quantized conv2d operation -int64_t quantized_conv2d_flop_calculator(const TestCase& test_case) { - int kernel_idx = 4; - if (test_case.operator_name().find("q8ta") != std::string::npos) { - kernel_idx = 7; - } - // Get input and weight dimensions - const auto& input_sizes = test_case.inputs()[0].get_tensor_sizes(); - const auto& output_sizes = test_case.outputs()[0].get_tensor_sizes(); - - const auto& kernel_sizes = test_case.inputs()[kernel_idx].get_int32_data(); - - int64_t N = input_sizes[0]; - int64_t C_in = input_sizes[1]; - int64_t C_out = output_sizes[1]; - int64_t K_h = kernel_sizes[0]; - int64_t K_w = kernel_sizes[1]; - int64_t H_out = output_sizes[2]; - int64_t W_out = output_sizes[3]; - - // Calculate FLOPs for quantized conv2d operation - // Each output element requires: - // - C_in * K_h * K_w multiply-accumulate operations - // - Additional operations for quantization/dequantization - int64_t output_elements = N * C_out * H_out * W_out; - int64_t ops_per_output = C_in * K_h * K_w; - - int64_t flop = output_elements * (ops_per_output); - - return flop; -} - -namespace { - -// Reference implementation for weight only quantized conv2d (fp accumulation) -void conv2d_q8csw_reference_impl(TestCase& test_case) { - int32_t idx = 0; - const ValueSpec& input_spec = test_case.inputs()[idx++]; - const ValueSpec& weight_spec = test_case.inputs()[idx++]; - const ValueSpec& weight_scales_spec = test_case.inputs()[idx++]; - const ValueSpec& bias_spec = test_case.inputs()[idx++]; - const ValueSpec& kernel_size_spec = test_case.inputs()[idx++]; - const ValueSpec& stride_spec = test_case.inputs()[idx++]; - const ValueSpec& padding_spec = test_case.inputs()[idx++]; - const ValueSpec& dilation_spec = test_case.inputs()[idx++]; - const ValueSpec& groups_spec = test_case.inputs()[idx++]; - - // Extract output specification (mutable reference) - ValueSpec& output_spec = test_case.outputs()[0]; - - // Get tensor dimensions - auto input_sizes = input_spec.get_tensor_sizes(); // [N, C_in, H_in, W_in] - auto weight_sizes = - weight_spec.get_tensor_sizes(); // [C_out, C_in_per_group * K_h * K_w] - auto output_sizes = - output_spec.get_tensor_sizes(); // [N, C_out, H_out, W_out] - - int64_t N = input_sizes[0]; - int64_t C_in = input_sizes[1]; - int64_t H_in = input_sizes[2]; - int64_t W_in = input_sizes[3]; - int64_t C_out = output_sizes[1]; - int64_t H_out = output_sizes[2]; - int64_t W_out = output_sizes[3]; - - // Get kernel dimensions from kernel_size ValueSpec - auto kernel_size_data = kernel_size_spec.get_int32_data(); - int64_t K_h = kernel_size_data[0]; - int64_t K_w = kernel_size_data[1]; - - // Get stride, padding, dilation, and groups - auto stride_data = stride_spec.get_int32_data(); - auto padding_data = padding_spec.get_int32_data(); - auto dilation_data = dilation_spec.get_int32_data(); - int64_t stride_h = stride_data[0]; - int64_t stride_w = stride_data[1]; - int64_t pad_h = padding_data[0]; - int64_t pad_w = padding_data[1]; - int64_t dilation_h = dilation_data[0]; - int64_t dilation_w = dilation_data[1]; - int64_t groups = groups_spec.get_int_value(); - - // Skip for large tensors since computation time will be extremely slow - if (N > kRefDimSizeLimit || C_in > kRefDimSizeLimit || - H_in > kRefDimSizeLimit || W_in > kRefDimSizeLimit || - C_out > kRefDimSizeLimit) { - throw std::invalid_argument( - "One or more dimensions exceed the allowed limit for reference implementation."); - } - - if (input_spec.dtype != vkapi::kFloat) { - throw std::invalid_argument("Unsupported dtype"); - } - - // Get raw data pointers - auto& input_data = input_spec.get_float_data(); - auto& weight_data = weight_spec.get_int8_data(); - auto& weight_scales_data = weight_scales_spec.get_float_data(); - auto& bias_data = bias_spec.get_float_data(); - - // Calculate channels per group for grouped convolution - int64_t C_in_per_group = C_in / groups; - int64_t C_out_per_group = C_out / groups; - - // Calculate number of output elements - int64_t num_output_elements = N * C_out * H_out * W_out; - - auto& ref_data = output_spec.get_ref_float_data(); - ref_data.resize(num_output_elements); - - const int in_features = - utils::safe_downcast(utils::align_up_4(C_in_per_group * K_h * K_w)); - - // Perform weight-only quantized conv2d operation (fp accumulation) - for (int64_t n = 0; n < N; ++n) { - for (int64_t out_c = 0; out_c < C_out; ++out_c) { - for (int64_t out_h = 0; out_h < H_out; ++out_h) { - for (int64_t out_w = 0; out_w < W_out; ++out_w) { - float sum = 0.0f; - - // Determine which group this output channel belongs to - int64_t group_idx = out_c / C_out_per_group; - int64_t in_c_start = group_idx * C_in_per_group; - int64_t in_c_end = (group_idx + 1) * C_in_per_group; - - // Convolution operation with dilation support and grouped convolution - for (int64_t in_c = in_c_start; in_c < in_c_end; ++in_c) { - for (int64_t kh = 0; kh < K_h; ++kh) { - for (int64_t kw = 0; kw < K_w; ++kw) { - // Calculate input position with dilation - int64_t in_h = out_h * stride_h - pad_h + kh * dilation_h; - int64_t in_w = out_w * stride_w - pad_w + kw * dilation_w; - - // Check bounds (zero padding) - if (in_h >= 0 && in_h < H_in && in_w >= 0 && in_w < W_in) { - // Get input value (keep as float) - int64_t input_idx = n * (C_in * H_in * W_in) + - in_c * (H_in * W_in) + in_h * W_in + in_w; - float input_val = input_data[input_idx]; - - // Get weight value and dequantize - // Weight layout: [C_out, C_in_per_group * K_h * K_w] - int64_t weight_idx = out_c * in_features + - (kh * (K_w * C_in_per_group) + kw * C_in_per_group + - (in_c % C_in_per_group)); - float dequant_weight = - (static_cast(weight_data[weight_idx])) * - weight_scales_data[out_c]; - - sum += input_val * dequant_weight; - } - } - } - } - - // Add bias and store result - sum += bias_data[out_c]; - int64_t output_idx = n * (C_out * H_out * W_out) + - out_c * (H_out * W_out) + out_h * W_out + out_w; - ref_data[output_idx] = sum; - } - } - } - } -} - -// Reference implementation for activation and weight quantized conv2d (int -// accumulation) -void conv2d_q8ta_q8csw_reference_impl(TestCase& test_case) { - // Extract input specifications - int32_t idx = 0; - const ValueSpec& input_spec = test_case.inputs()[idx++]; - const ValueSpec& input_scale_spec = test_case.inputs()[idx++]; - const ValueSpec& input_zeros_spec = test_case.inputs()[idx++]; - const ValueSpec& weight_spec = test_case.inputs()[idx++]; - const ValueSpec& weight_sums_spec = test_case.inputs()[idx++]; - (void)weight_sums_spec; - const ValueSpec& weight_scales_spec = test_case.inputs()[idx++]; - const ValueSpec& bias_spec = test_case.inputs()[idx++]; - const ValueSpec& kernel_size_spec = test_case.inputs()[idx++]; - const ValueSpec& stride_spec = test_case.inputs()[idx++]; - const ValueSpec& padding_spec = test_case.inputs()[idx++]; - const ValueSpec& dilation_spec = test_case.inputs()[idx++]; - const ValueSpec& groups_spec = test_case.inputs()[idx++]; - - // Extract output specification (mutable reference) - ValueSpec& output_spec = test_case.outputs()[0]; - - // Get tensor dimensions - auto input_sizes = input_spec.get_tensor_sizes(); // [N, C_in, H_in, W_in] - auto weight_sizes = - weight_spec.get_tensor_sizes(); // [C_out, C_in_per_group * K_h * K_w] - auto output_sizes = - output_spec.get_tensor_sizes(); // [N, C_out, H_out, W_out] - - int64_t N = input_sizes[0]; - int64_t C_in = input_sizes[1]; - int64_t H_in = input_sizes[2]; - int64_t W_in = input_sizes[3]; - int64_t C_out = output_sizes[1]; - int64_t H_out = output_sizes[2]; - int64_t W_out = output_sizes[3]; - - // Get kernel dimensions from kernel_size ValueSpec - auto kernel_size_data = kernel_size_spec.get_int32_data(); - int64_t K_h = kernel_size_data[0]; - int64_t K_w = kernel_size_data[1]; - - // Get stride, padding, dilation, and groups - auto stride_data = stride_spec.get_int32_data(); - auto padding_data = padding_spec.get_int32_data(); - auto dilation_data = dilation_spec.get_int32_data(); - int64_t stride_h = stride_data[0]; - int64_t stride_w = stride_data[1]; - int64_t pad_h = padding_data[0]; - int64_t pad_w = padding_data[1]; - int64_t dilation_h = dilation_data[0]; - int64_t dilation_w = dilation_data[1]; - int64_t groups = groups_spec.get_int_value(); - - // Skip for large tensors since computation time will be extremely slow - if (N > kRefDimSizeLimit || C_in > kRefDimSizeLimit || - H_in > kRefDimSizeLimit || W_in > kRefDimSizeLimit || - C_out > kRefDimSizeLimit) { - throw std::invalid_argument( - "One or more dimensions exceed the allowed limit for reference implementation."); - } - - if (input_spec.dtype != vkapi::kFloat) { - throw std::invalid_argument("Unsupported dtype"); - } - - // Get raw data pointers - auto& input_data = input_spec.get_float_data(); - const float input_scale = input_scale_spec.get_float_value(); - const int32_t input_zero_point = input_zeros_spec.get_int_value(); - - auto& weight_data = weight_spec.get_int8_data(); - auto& weight_scales_data = weight_scales_spec.get_float_data(); - auto& bias_data = bias_spec.get_float_data(); - - // Calculate channels per group for grouped convolution - int64_t C_in_per_group = C_in / groups; - int64_t C_out_per_group = C_out / groups; - - // Calculate number of output elements - int64_t num_output_elements = N * C_out * H_out * W_out; - - auto& ref_data = output_spec.get_ref_float_data(); - ref_data.resize(num_output_elements); - - const int in_features = - utils::safe_downcast(utils::align_up_4(C_in_per_group * K_h * K_w)); - - // Perform activation and weight quantized conv2d operation (int accumulation) - for (int64_t n = 0; n < N; ++n) { - for (int64_t out_c = 0; out_c < C_out; ++out_c) { - for (int64_t out_h = 0; out_h < H_out; ++out_h) { - for (int64_t out_w = 0; out_w < W_out; ++out_w) { - int32_t int_sum = 0; - int32_t weight_sum = 0; // Track weight sum on the fly - - // Determine which group this output channel belongs to - int64_t group_idx = out_c / C_out_per_group; - int64_t in_c_start = group_idx * C_in_per_group; - int64_t in_c_end = (group_idx + 1) * C_in_per_group; - - // Convolution operation with integer accumulation - for (int64_t in_c = in_c_start; in_c < in_c_end; ++in_c) { - for (int64_t kh = 0; kh < K_h; ++kh) { - for (int64_t kw = 0; kw < K_w; ++kw) { - // Calculate input position with dilation - int64_t in_h = out_h * stride_h - pad_h + kh * dilation_h; - int64_t in_w = out_w * stride_w - pad_w + kw * dilation_w; - - // Check bounds (zero padding) - if (in_h >= 0 && in_h < H_in && in_w >= 0 && in_w < W_in) { - // Get input value and quantize to int8 - int64_t input_idx = n * (C_in * H_in * W_in) + - in_c * (H_in * W_in) + in_h * W_in + in_w; - - float quant_input_f = - std::round(input_data[input_idx] / input_scale) + - input_zero_point; - quant_input_f = - std::min(std::max(quant_input_f, -128.0f), 127.0f); - int8_t quantized_input = static_cast(quant_input_f); - - // Get quantized weight (already int8) - // Weight layout: [C_out, C_in_per_group * K_h * K_w] - int64_t weight_idx = out_c * in_features + - (kh * (K_w * C_in_per_group) + kw * C_in_per_group + - (in_c % C_in_per_group)); - int8_t quantized_weight = weight_data[weight_idx]; - - // Integer multiplication and accumulation - int_sum += static_cast(quantized_input) * - static_cast(quantized_weight); - - // Track weight sum for this output channel on the fly - weight_sum += static_cast(quantized_weight); - } else { - // For zero padding, we still need to account for the weight - // in weight_sum when input is effectively 0 (but quantized 0 - // is input_zero_point) - int64_t weight_idx = out_c * in_features + - (kh * (K_w * C_in_per_group) + kw * C_in_per_group + - (in_c % C_in_per_group)); - int8_t quantized_weight = weight_data[weight_idx]; - - // Add contribution from zero-padded input (quantized zero = - // input_zero_point) - int_sum += static_cast(input_zero_point) * - static_cast(quantized_weight); - - // Track weight sum for this output channel on the fly - weight_sum += static_cast(quantized_weight); - } - } - } - } - - // Convert accumulated integer result to float and apply scales - // Final result = (int_sum - zero_point_correction) * input_scale * - // weight_scale + bias zero_point_correction = input_zero_point * - // sum_of_weights_for_this_output_channel - int32_t zero_point_correction = input_zero_point * weight_sum; - int32_t accum_adjusted = int_sum - zero_point_correction; - float float_result = - accum_adjusted * input_scale * weight_scales_data[out_c]; - - // Add bias and store result - float_result += bias_data[out_c]; - int64_t output_idx = n * (C_out * H_out * W_out) + - out_c * (H_out * W_out) + out_h * W_out + out_w; - ref_data[output_idx] = float_result; - } - } - } - } -} - -} // namespace - -void reference_impl(TestCase& test_case) { - if (test_case.operator_name().find("q8ta") != std::string::npos) { - conv2d_q8ta_q8csw_reference_impl(test_case); - } else { - conv2d_q8csw_reference_impl(test_case); - } -} - -} // namespace q8csw_conv2d -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8csw_conv2d/q8csw_conv2d.h b/backends/vulkan/test/ops/benchmark/cases/q8csw_conv2d/q8csw_conv2d.h deleted file mode 100644 index 0cf3f5a4278..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8csw_conv2d/q8csw_conv2d.h +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#pragma once - -#include -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8csw_conv2d { - -constexpr int64_t kRefDimSizeLimit = 100; - -TestCase create_test_case_from_config( - const Conv2dConfig& config, - utils::StorageType storage_type, - vkapi::ScalarType input_dtype); - -void reference_impl(TestCase& test_case); - -int64_t quantized_conv2d_flop_calculator(const TestCase& test_case); - -} // namespace q8csw_conv2d -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8csw_conv2d/test_sets/q8csw_conv2d_sets.cpp b/backends/vulkan/test/ops/benchmark/cases/q8csw_conv2d/test_sets/q8csw_conv2d_sets.cpp deleted file mode 100644 index 6bee244b04d..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8csw_conv2d/test_sets/q8csw_conv2d_sets.cpp +++ /dev/null @@ -1,202 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8csw_conv2d { - -namespace { - -std::vector generate_test_cases( - const std::vector& configs, - const std::vector& storage_types) { - std::vector test_cases; - - // Generate test cases for each combination - for (auto config : configs) { - for (const auto& storage_type : storage_types) { - // Generate test case name programmatically - bool is_performance = config.channels.out > kRefDimSizeLimit || - config.channels.in > kRefDimSizeLimit || - config.input_size.h > kRefDimSizeLimit || - config.input_size.w > kRefDimSizeLimit; - std::string prefix = is_performance ? "performance_" : "correctness_"; - std::string suffix = std::to_string(config.channels.out) + "/" + - std::to_string(config.channels.in) + "_" + - std::to_string(config.input_size.h) + "/" + - std::to_string(config.input_size.w) + "_" + - std::to_string(config.kernel.h) + "/" + - std::to_string(config.kernel.w); - - config.op_name = "conv2d_q8ta_q8csw"; - config.test_case_name = prefix + suffix; - // The default operator tested is activation + weight quantized conv2d; - // however, only test this if the int8 dot product extension is supported - if (vkcompute::api::context() - ->adapter_ptr() - ->supports_int8_dot_product()) { - test_cases.push_back( - create_test_case_from_config(config, storage_type, vkapi::kFloat)); - } - - Conv2dConfig wo_quant_config = config; - wo_quant_config.op_name = "conv2d_q8csw"; - test_cases.push_back(create_test_case_from_config( - wo_quant_config, storage_type, vkapi::kFloat)); - } - } - - return test_cases; -} - -} // namespace - -REGISTER_TEST_CASE_SET("q8csw_conv2d", "correctness") { - const std::vector configs = { - {OutInChannels(32, 3), - InputSize2D(64, 64), - KernelSize(3, 3), - Stride(2, 2), - Padding(1, 1), - Dilation(1, 1), - 1}, - {OutInChannels(32, 16), - InputSize2D(32, 32), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 1}, - {OutInChannels(64, 32), - InputSize2D(16, 16), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 1}, - // One output channel case - {OutInChannels(1, 32), - InputSize2D(55, 55), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 1}, - - // Stride 2 convolutions - {OutInChannels(32, 3), - InputSize2D(64, 64), - KernelSize(3, 3), - Stride(2, 2), - Padding(1, 1), - Dilation(1, 1), - 1}, - {OutInChannels(64, 32), - InputSize2D(32, 32), - KernelSize(3, 3), - Stride(2, 2), - Padding(1, 1), - Dilation(1, 1), - 1}, - // Different kernel sizes - {OutInChannels(32, 16), - InputSize2D(28, 28), - KernelSize(5, 5), - Stride(1, 1), - Padding(2, 2), - Dilation(1, 1), - 1}, - {OutInChannels(64, 32), - InputSize2D(14, 14), - KernelSize(7, 7), - Stride(1, 1), - Padding(3, 3), - Dilation(1, 1), - 1}, - - // Dilated convolutions - {OutInChannels(32, 16), - InputSize2D(32, 32), - KernelSize(3, 3), - Stride(1, 1), - Padding(2, 2), - Dilation(2, 2), - 1}, - {OutInChannels(64, 32), - InputSize2D(16, 16), - KernelSize(3, 3), - Stride(1, 1), - Padding(3, 3), - Dilation(3, 3), - 1}, - - // Grouped convolutions - {OutInChannels(32, 32), - InputSize2D(32, 32), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 4}, - {OutInChannels(64, 64), - InputSize2D(16, 16), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 8}, - }; - const std::vector storage_types = {utils::kTexture3D}; - - return { - generate_test_cases(configs, storage_types), - reference_impl, - quantized_conv2d_flop_calculator}; -} - -REGISTER_TEST_CASE_SET("q8csw_conv2d", "performance") { - const std::vector configs = { - // Performance test cases - {OutInChannels(256, 128), - InputSize2D(128, 128), - KernelSize(1, 1), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 1}, - {OutInChannels(128, 64), - InputSize2D(128, 128), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 1}, - {OutInChannels(128, 1024), - InputSize2D(128, 128), - KernelSize(1, 1), - Stride(1, 1), - Padding(0, 0), - Dilation(1, 1), - 1}, - }; - const std::vector storage_types = {utils::kTexture3D}; - - return { - generate_test_cases(configs, storage_types), - reference_impl, - quantized_conv2d_flop_calculator}; -} - -} // namespace q8csw_conv2d -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8csw_linear/q8csw_linear.cpp b/backends/vulkan/test/ops/benchmark/cases/q8csw_linear/q8csw_linear.cpp deleted file mode 100644 index 4718ab4310e..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8csw_linear/q8csw_linear.cpp +++ /dev/null @@ -1,377 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include -#include -#include -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8csw_linear { - -// Utility function to create a test case from a LinearConfig -TestCase create_test_case_from_config( - const LinearConfig& config, - utils::StorageType storage_type, - vkapi::ScalarType input_dtype) { - TestCase test_case; - - // Create a descriptive name for the test case - bool is_perf = - !(config.M < kRefDimSizeLimit && config.K < kRefDimSizeLimit && - config.N < kRefDimSizeLimit); - std::string prefix = is_perf ? "PERF" : "ACCU"; - std::string dtype_str = dtype_short(input_dtype); - std::string shape_str = "[" + std::to_string(config.M) + "," + - std::to_string(config.K) + "]x[" + std::to_string(config.N) + "," + - std::to_string(config.K) + "]"; - utils::GPUMemoryLayout layout = (storage_type == utils::kTexture3D) - ? utils::kChannelsPacked - : utils::kWidthPacked; - std::string storage_str = repr_str(storage_type, layout); - std::string suffix = "[" + config.op_name + "]"; - if (!config.has_bias) { - suffix += " no_bias"; - } - std::string test_name = make_test_label( - prefix, dtype_str, dtype_str, shape_str, storage_str, suffix); - test_case.set_name(test_name); - - // Set the operator name for the test case - std::string operator_name = "et_vk." + config.op_name + ".default"; - test_case.set_operator_name(operator_name); - - // Derive sizes from M, K, N - std::vector input_size = {config.M, config.K}; - std::vector weight_size = {config.N, config.K}; - - // Input tensor (float/half) - [M, K] - ValueSpec input_tensor( - input_size, - input_dtype, - storage_type, - utils::kWidthPacked, - DataGenType::RANDOM); - - if (debugging()) { - print_valuespec_data(input_tensor, "input_tensor"); - } - - float input_scale_val = 0.008f; - ValueSpec input_scale(input_scale_val); - - int32_t input_zero_point_val = -2; - ValueSpec input_zero_point(input_zero_point_val); - - // Quantized weight tensor (int8) - [K, N] - ValueSpec quantized_weight( - weight_size, - vkapi::kChar, // int8 for quantized weights - storage_type, - utils::kWidthPacked, - DataGenType::RANDINT8); - quantized_weight.set_constant(true); - - if (debugging()) { - print_valuespec_data(quantized_weight, "weight_tensor"); - } - - // Weight quantization scales (float/half, per-channel) - ValueSpec weight_scales( - {config.N}, // Per output feature - input_dtype, - storage_type, - utils::kWidthPacked, - DataGenType::RANDOM_SCALES); - weight_scales.set_constant(true); - - ValueSpec weight_sums( - {config.N}, // Per output features - vkapi::kInt, - storage_type, - utils::kWidthPacked, - DataGenType::ZEROS); - weight_sums.set_constant(true); - - // Compute weight_sums data based on quantized weights - int64_t in_features = config.K; - int64_t out_features = config.N; - compute_weight_sums(weight_sums, quantized_weight, out_features, in_features); - - // Bias (optional, float/half) - [N] - ValueSpec bias( - {config.N}, // Per output feature - input_dtype, - storage_type, - utils::kWidthPacked, - DataGenType::RANDOM); - bias.set_constant(true); - if (!config.has_bias) { - bias.set_none(true); - } - - // Output tensor (float/half) - [M, N] - ValueSpec output( - {config.M, config.N}, - input_dtype, - storage_type, - utils::kWidthPacked, - DataGenType::ZEROS); - - // Add all specs to test case - if (config.op_name.find("q8ta") != std::string::npos) { - test_case.add_input_spec(input_tensor); - test_case.add_input_spec(input_scale); - test_case.add_input_spec(input_zero_point); - test_case.add_input_spec(quantized_weight); - test_case.add_input_spec(weight_sums); - test_case.add_input_spec(weight_scales); - test_case.add_input_spec(bias); - test_case.add_output_spec(output); - } else { - test_case.add_input_spec(input_tensor); - test_case.add_input_spec(quantized_weight); - test_case.add_input_spec(weight_scales); - test_case.add_input_spec(bias); - test_case.add_output_spec(output); - } - - return test_case; -} - -int64_t quantized_linear_flop_calculator(const TestCase& test_case) { - int input_idx = 0; - int weight_idx = 1; - if (test_case.operator_name().find("q8ta") != std::string::npos) { - input_idx = 0; - weight_idx = 3; - } - - // Get input and weight dimensions - const auto& input_sizes = test_case.inputs()[input_idx].get_tensor_sizes(); - const auto& weight_sizes = test_case.inputs()[weight_idx].get_tensor_sizes(); - - int64_t batch_size = input_sizes[0]; - int64_t in_features = input_sizes[1]; - int64_t out_features = weight_sizes[0]; - - // Calculate FLOPs for quantized linear operation - // Each output element requires: - // - in_features multiply-accumulate operations - // - Additional operations for quantization/dequantization - int64_t output_elements = batch_size * out_features; - int64_t ops_per_output = in_features; - - // Add quantization overhead (approximate) - // - Dequantize input: 1 op per input element used - // - Dequantize weight: 1 op per weight element used - // - Add bias: 1 op per output element - int64_t quantization_ops = ops_per_output + 1; // Simplified estimate - - int64_t flop = output_elements * (ops_per_output + quantization_ops); - - return flop; -} - -namespace { - -// Reference implementation for weight only quantized linear -void linear_q8csw_reference_impl(TestCase& test_case) { - int32_t idx = 0; - const ValueSpec& input_spec = test_case.inputs()[idx++]; - const ValueSpec& weight_spec = test_case.inputs()[idx++]; - const ValueSpec& weight_scales_spec = test_case.inputs()[idx++]; - const ValueSpec& bias_spec = test_case.inputs()[idx++]; - - // Extract output specification (mutable reference) - ValueSpec& output_spec = test_case.outputs()[0]; - - // Get tensor dimensions - auto input_sizes = input_spec.get_tensor_sizes(); // [batch_size, in_features] - auto weight_sizes = - weight_spec.get_tensor_sizes(); // [out_features, in_features] - auto output_sizes = - output_spec.get_tensor_sizes(); // [batch_size, out_features] - - int64_t batch_size = input_sizes[0]; - int64_t in_features = input_sizes[1]; - int64_t out_features = weight_sizes[0]; - - // Skip for large tensors since computation time will be extremely slow - if (batch_size > kRefDimSizeLimit || in_features > kRefDimSizeLimit || - out_features > kRefDimSizeLimit) { - throw std::invalid_argument( - "One or more dimensions (batch_size, in_features, out_features) exceed the allowed limit for reference implementation."); - } - - if (input_spec.dtype != vkapi::kFloat) { - throw std::invalid_argument("Unsupported dtype"); - } - - // Get raw data pointers - auto& input_data = input_spec.get_float_data(); - - auto& weight_data = weight_spec.get_int8_data(); - auto& weight_scales_data = weight_scales_spec.get_float_data(); - auto& bias_data = bias_spec.get_float_data(); - - // Calculate number of output elements - int64_t num_output_elements = batch_size * out_features; - - auto& ref_data = output_spec.get_ref_float_data(); - ref_data.resize(num_output_elements); - - // Perform quantized linear transformation (matrix multiplication) - for (int64_t b = 0; b < batch_size; ++b) { - for (int64_t out_f = 0; out_f < out_features; ++out_f) { - float sum = 0.0f; - - // Matrix multiplication: output[b][out_f] = sum(input[b][in_f] * - // weight[out_f][in_f]) - for (int64_t in_f = 0; in_f < in_features; ++in_f) { - // Get input value and dequantize - int64_t input_idx = b * in_features + in_f; - float input_val = input_data[input_idx]; - - // Get weight value and dequantize - int64_t weight_idx = out_f * in_features + in_f; - float dequant_weight = (static_cast(weight_data[weight_idx])) * - weight_scales_data[out_f]; - - sum += input_val * dequant_weight; - } - - // Add bias and store result - if (!bias_spec.is_none()) { - sum += bias_data[out_f]; - } - int64_t output_idx = b * out_features + out_f; - ref_data[output_idx] = sum; - } - } -} - -void linear_q8ta_q8csw_reference_impl(TestCase& test_case) { - // Extract input specifications - int32_t idx = 0; - const ValueSpec& input_spec = test_case.inputs()[idx++]; - const ValueSpec& input_scale_spec = test_case.inputs()[idx++]; - const ValueSpec& input_zeros_spec = test_case.inputs()[idx++]; - const ValueSpec& weight_spec = test_case.inputs()[idx++]; - const ValueSpec& weight_sums_spec = test_case.inputs()[idx++]; - (void)weight_sums_spec; - const ValueSpec& weight_scales_spec = test_case.inputs()[idx++]; - const ValueSpec& bias_spec = test_case.inputs()[idx++]; - - // Extract output specification (mutable reference) - ValueSpec& output_spec = test_case.outputs()[0]; - - // Get tensor dimensions - auto input_sizes = input_spec.get_tensor_sizes(); // [batch_size, in_features] - auto weight_sizes = - weight_spec.get_tensor_sizes(); // [out_features, in_features] - auto output_sizes = - output_spec.get_tensor_sizes(); // [batch_size, out_features] - - int64_t batch_size = input_sizes[0]; - int64_t in_features = input_sizes[1]; - int64_t out_features = weight_sizes[0]; - - // Skip for large tensors since computation time will be extremely slow - if (batch_size > kRefDimSizeLimit || in_features > kRefDimSizeLimit || - out_features > kRefDimSizeLimit) { - throw std::invalid_argument( - "One or more dimensions (batch_size, in_features, out_features) exceed the allowed limit for reference implementation."); - } - - if (input_spec.dtype != vkapi::kFloat) { - throw std::invalid_argument("Unsupported dtype"); - } - - // Get raw data pointers - auto& input_data = input_spec.get_float_data(); - const float input_scale = input_scale_spec.get_float_value(); - const int32_t input_zero_point = input_zeros_spec.get_int_value(); - - auto& weight_data = weight_spec.get_int8_data(); - auto& weight_scales_data = weight_scales_spec.get_float_data(); - auto& bias_data = bias_spec.get_float_data(); - - // Calculate number of output elements - int64_t num_output_elements = batch_size * out_features; - - auto& ref_data = output_spec.get_ref_float_data(); - ref_data.resize(num_output_elements); - - // Perform quantized linear transformation (matrix multiplication) with - // integer accumulation - for (int64_t b = 0; b < batch_size; ++b) { - for (int64_t out_f = 0; out_f < out_features; ++out_f) { - int32_t int_sum = 0; - int32_t weight_sum = 0; // Track weight sum on the fly - - // Matrix multiplication with integer accumulation: - // int_sum = sum(quantized_input[b][in_f] * quantized_weight[out_f][in_f]) - for (int64_t in_f = 0; in_f < in_features; ++in_f) { - // Get input value and quantize to int8 - int64_t input_idx = b * in_features + in_f; - - float quant_input_f = - std::round(input_data[input_idx] / input_scale) + input_zero_point; - quant_input_f = std::min(std::max(quant_input_f, -128.0f), 127.0f); - int8_t quantized_input = static_cast(quant_input_f); - - // Get quantized weight (already int8) - int64_t weight_idx = out_f * in_features + in_f; - int8_t quantized_weight = weight_data[weight_idx]; - - // Integer multiplication and accumulation - int_sum += static_cast(quantized_input) * - static_cast(quantized_weight); - - // Track weight sum for this output channel on the fly - weight_sum += static_cast(quantized_weight); - } - - // Convert accumulated integer result to float and apply scales - // Final result = (int_sum - zero_point_correction) * input_scale * - // weight_scale + bias zero_point_correction = input_zero_point * - // sum_of_weights_for_this_output_channel - int32_t zero_point_correction = input_zero_point * weight_sum; - int32_t accum_adjusted = int_sum - zero_point_correction; - - float float_result = - accum_adjusted * input_scale * weight_scales_data[out_f]; - - // Add bias and store result - if (!bias_spec.is_none()) { - float_result += bias_data[out_f]; - } - int64_t output_idx = b * out_features + out_f; - ref_data[output_idx] = float_result; - } - } -} - -} // namespace - -void reference_impl(TestCase& test_case) { - if (test_case.operator_name().find("q8ta") != std::string::npos) { - linear_q8ta_q8csw_reference_impl(test_case); - } else { - linear_q8csw_reference_impl(test_case); - } -} - -} // namespace q8csw_linear -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8csw_linear/q8csw_linear.h b/backends/vulkan/test/ops/benchmark/cases/q8csw_linear/q8csw_linear.h deleted file mode 100644 index 3a3df55ad63..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8csw_linear/q8csw_linear.h +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#pragma once - -#include -#include - -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8csw_linear { - -constexpr int64_t kRefDimSizeLimit = 300; - -// Linear configuration struct -struct LinearConfig { - int64_t M; // Batch size / number of rows in input - int64_t K; // Input features / columns in input, rows in weight - int64_t N; // Output features / columns in weight - bool has_bias = true; - std::string test_case_name = "placeholder"; - std::string op_name = "linear_q8ta_q8csw"; -}; - -TestCase create_test_case_from_config( - const LinearConfig& config, - utils::StorageType storage_type, - vkapi::ScalarType input_dtype); - -void reference_impl(TestCase& test_case); - -int64_t quantized_linear_flop_calculator(const TestCase& test_case); - -} // namespace q8csw_linear -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8csw_linear/test_sets/debug_set.cpp b/backends/vulkan/test/ops/benchmark/cases/q8csw_linear/test_sets/debug_set.cpp deleted file mode 100644 index 5402016b048..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8csw_linear/test_sets/debug_set.cpp +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8csw_linear { - -namespace { - -std::vector generate_single_op_test_cases( - const std::vector& configs, - const std::vector& storage_types, - const std::vector& dtypes) { - std::vector test_cases; - - for (const auto& config : configs) { - for (const auto& storage_type : storage_types) { - for (const auto& input_dtype : dtypes) { - test_cases.push_back( - create_test_case_from_config(config, storage_type, input_dtype)); - } - } - } - - return test_cases; -} - -} // namespace - -REGISTER_TEST_CASE_SET("q8csw_linear", "debug") { - const std::vector configs = { - // Single simple configuration for debugging - {4, 4, 4, true, "simple"}, - }; - const std::vector storage_types = { - utils::kTexture3D, utils::kBuffer}; - const std::vector dtypes = {vkapi::kFloat}; - - return { - generate_single_op_test_cases(configs, storage_types, dtypes), - reference_impl, - quantized_linear_flop_calculator}; -} - -} // namespace q8csw_linear -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8csw_linear/test_sets/q8csw_linear_sets.cpp b/backends/vulkan/test/ops/benchmark/cases/q8csw_linear/test_sets/q8csw_linear_sets.cpp deleted file mode 100644 index 4cbea88deb9..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8csw_linear/test_sets/q8csw_linear_sets.cpp +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8csw_linear { - -namespace { - -std::vector generate_test_cases( - const std::vector& configs, - const std::vector& storage_types) { - std::vector test_cases; - - for (auto config : configs) { - std::string prefix = - (config.M < kRefDimSizeLimit && config.K < kRefDimSizeLimit && - config.N < kRefDimSizeLimit) - ? "correctness_" - : "performance_"; - std::string generated_test_case_name = prefix + std::to_string(config.M) + - "_" + std::to_string(config.K) + "_" + std::to_string(config.N); - if (!config.has_bias) { - generated_test_case_name += "_no_bias"; - } - - config.test_case_name = generated_test_case_name; - - for (const auto& storage_type : storage_types) { - if (vkcompute::api::context() - ->adapter_ptr() - ->supports_int8_dot_product()) { - // Test both activation+weight quantized and weight only quantized - test_cases.push_back( - create_test_case_from_config(config, storage_type, vkapi::kFloat)); - } - - LinearConfig wo_quant_config = config; - wo_quant_config.op_name = "linear_q8csw"; - test_cases.push_back(create_test_case_from_config( - wo_quant_config, storage_type, vkapi::kFloat)); - } - } - - return test_cases; -} - -} // namespace - -REGISTER_TEST_CASE_SET("q8csw_linear", "correctness") { - const std::vector configs = { - {4, 64, 32}, - {4, 128, 64}, - {4, 256, 128}, - {32, 64, 32}, - {32, 128, 64}, - {32, 256, 128}, - // No bias tests - {32, 128, 64, false}, - {32, 256, 128, false}, - }; - const std::vector storage_types = { - utils::kTexture3D, utils::kBuffer}; - - return { - generate_test_cases(configs, storage_types), - reference_impl, - quantized_linear_flop_calculator}; -} - -REGISTER_TEST_CASE_SET("q8csw_linear", "performance") { - const std::vector configs = { - {256, 2048, 2048}, - {512, 2048, 2048}, - {1024, 2048, 2048}, - }; - const std::vector storage_types = { - utils::kTexture3D, utils::kBuffer}; - - return { - generate_test_cases(configs, storage_types), - reference_impl, - quantized_linear_flop_calculator}; -} - -} // namespace q8csw_linear -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8ta/binary/binary.cpp b/backends/vulkan/test/ops/benchmark/cases/q8ta/binary/binary.cpp deleted file mode 100644 index 7c878a2b485..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8ta/binary/binary.cpp +++ /dev/null @@ -1,282 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include -#include -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8ta_binary { - -namespace { - -std::vector broadcast_sizes( - const std::vector& a, - const std::vector& b) { - std::vector out(std::max(a.size(), b.size()), 1); - for (size_t i = 0; i < out.size(); ++i) { - const int64_t a_size = i < a.size() ? a[a.size() - 1 - i] : 1; - const int64_t b_size = i < b.size() ? b[b.size() - 1 - i] : 1; - out[out.size() - 1 - i] = std::max(a_size, b_size); - } - return out; -} - -int64_t broadcast_src_idx( - int64_t out_idx, - const std::vector& out_sizes, - const std::vector& sizes) { - int64_t src_idx = 0; - int64_t stride = 1; - int64_t d = static_cast(sizes.size()) - 1; - for (int64_t od = static_cast(out_sizes.size()) - 1; od >= 0; - --od, --d) { - const int64_t coord = out_idx % out_sizes[od]; - out_idx /= out_sizes[od]; - if (d >= 0) { - if (sizes[d] != 1) { - src_idx += coord * stride; - } - stride *= sizes[d]; - } - } - return src_idx; -} - -} // namespace - -// Utility function to create a test case from a Q8taBinaryConfig -TestCase create_test_case_from_config( - const Q8taBinaryConfig& config, - utils::StorageType storage_type, - vkapi::ScalarType input_dtype, - utils::GPUMemoryLayout fp_memory_layout, - utils::GPUMemoryLayout quant_layout, - bool const_b) { - TestCase test_case; - - const std::vector& other_shape = - config.other_shape.empty() ? config.shape : config.other_shape; - const std::vector out_shape = - broadcast_sizes(config.shape, other_shape); - - // Create a descriptive name for the test case - // q8ta binary: i8->i8, two inputs added together - std::string prefix = config.test_case_name; // "ACCU" or "PERF" - std::string shape_str = - shape_bracket(config.shape) + "+" + shape_bracket(other_shape); - std::string storage_str = repr_str(utils::kBuffer, quant_layout); - std::string suffix = const_b ? "[const_b]" : ""; - std::string test_name = make_test_label( - prefix, - dtype_short(input_dtype), - dtype_short(input_dtype), - shape_str, - storage_str, - suffix); - test_case.set_name(test_name); - - // Set the operator name for the test case - std::string operator_name = "et_vk." + config.op_name + ".test"; - test_case.set_operator_name(operator_name); - - // Input tensor A (float/half) - ValueSpec input_a( - config.shape, - input_dtype, - storage_type, - fp_memory_layout, - DataGenType::RANDOM); - - // Input tensor B (float/half, or pre-quantized int8 for const_b) - ValueSpec input_b( - other_shape, - const_b ? vkapi::kChar : input_dtype, - storage_type, - fp_memory_layout, - const_b ? DataGenType::RANDINT8 : DataGenType::RANDOM); - if (const_b) { - input_b.set_constant(true); - } - - // Quantization parameters for input A - float input_a_scale_val = 0.007843; // 2/255 approximately - ValueSpec input_a_scale(input_a_scale_val); - - int32_t input_a_zero_point_val = 3; - ValueSpec input_a_zero_point(input_a_zero_point_val); - - // Quantization parameters for input B - float input_b_scale_val = 0.009412; // 2.4/255 approximately - ValueSpec input_b_scale(input_b_scale_val); - - int32_t input_b_zero_point_val = -2; - ValueSpec input_b_zero_point(input_b_zero_point_val); - - // Output quantization parameters - float output_scale_val = 0.015686; // 4/255 approximately - ValueSpec output_scale(output_scale_val); - - int32_t output_zero_point_val = 1; - ValueSpec output_zero_point(output_zero_point_val); - - // Alpha parameter - float alpha_val = 1.0f; - ValueSpec alpha(alpha_val); - - // Quantized layout as integer - int32_t quant_layout_int = static_cast(quant_layout); - ValueSpec quant_layout_spec(quant_layout_int); - - // Output tensor (float/half) - ValueSpec output( - out_shape, - input_dtype, - storage_type, - fp_memory_layout, - DataGenType::ZEROS); - - // Add all specs to test case for q8ta add operation - test_case.add_input_spec(input_a); - test_case.add_input_spec(input_b); - test_case.add_input_spec(input_a_scale); - test_case.add_input_spec(input_a_zero_point); - test_case.add_input_spec(input_b_scale); - test_case.add_input_spec(input_b_zero_point); - test_case.add_input_spec(output_scale); - test_case.add_input_spec(output_zero_point); - test_case.add_input_spec(alpha); - test_case.add_input_spec(quant_layout_spec); - - test_case.add_output_spec(output); - - test_case.set_abs_tolerance(output_scale_val + 1e-4f); - - // Use layout-only filter to focus on the binary operation - test_case.set_shader_filter({ - "nchw_to", - "to_nchw", - "q8ta_quantize", - "q8ta_dequantize", - }); - - return test_case; -} - -// Reference implementation for quantized add operation -void q8ta_add_reference_impl(TestCase& test_case) { - int32_t idx = 0; - const ValueSpec& input_a_spec = test_case.inputs()[idx++]; - const ValueSpec& input_b_spec = test_case.inputs()[idx++]; - const ValueSpec& input_a_scale_spec = test_case.inputs()[idx++]; - const ValueSpec& input_a_zero_point_spec = test_case.inputs()[idx++]; - const ValueSpec& input_b_scale_spec = test_case.inputs()[idx++]; - const ValueSpec& input_b_zero_point_spec = test_case.inputs()[idx++]; - const ValueSpec& output_scale_spec = test_case.inputs()[idx++]; - const ValueSpec& output_zero_point_spec = test_case.inputs()[idx++]; - const ValueSpec& alpha_spec = test_case.inputs()[idx++]; - const ValueSpec& quant_layout_spec = test_case.inputs()[idx++]; - (void)quant_layout_spec; // Not used in reference implementation - - // Extract output specification - ValueSpec& output_spec = test_case.outputs()[0]; - - // Get tensor dimensions - const auto input_a_sizes = input_a_spec.get_tensor_sizes(); - const auto input_b_sizes = input_b_spec.get_tensor_sizes(); - const auto output_sizes = output_spec.get_tensor_sizes(); - - // Calculate total number of elements - int64_t num_elements = 1; - for (const auto& dim : output_sizes) { - num_elements *= dim; - } - - // Skip for large tensors since computation time will be extremely slow - for (const auto& dim : output_sizes) { - if (dim > kRefDimSizeLimit) { - throw std::invalid_argument( - "One or more dimensions exceed the allowed limit for reference " - "implementation."); - } - } - - if (input_a_spec.dtype != vkapi::kFloat) { - throw std::invalid_argument("Unsupported dtype"); - } - - bool input_b_is_int8 = (input_b_spec.dtype == vkapi::kChar); - - // Get raw data pointers - auto& input_a_data = input_a_spec.get_float_data(); - - const float input_a_scale = input_a_scale_spec.get_float_value(); - const int32_t input_a_zero_point = input_a_zero_point_spec.get_int_value(); - const float input_b_scale = input_b_scale_spec.get_float_value(); - const int32_t input_b_zero_point = input_b_zero_point_spec.get_int_value(); - const float output_scale = output_scale_spec.get_float_value(); - const int32_t output_zero_point = output_zero_point_spec.get_int_value(); - const float alpha = alpha_spec.get_float_value(); - - auto& ref_data = output_spec.get_ref_float_data(); - ref_data.resize(num_elements); - - // Perform quantized add operation - for (int64_t i = 0; i < num_elements; ++i) { - const int64_t a_idx = broadcast_src_idx(i, output_sizes, input_a_sizes); - const int64_t b_idx = broadcast_src_idx(i, output_sizes, input_b_sizes); - - // Quantize input A to int8 - float quant_a_f = - std::round(input_a_data[a_idx] / input_a_scale) + input_a_zero_point; - quant_a_f = std::min(std::max(quant_a_f, -128.0f), 127.0f); - int8_t quantized_a = static_cast(quant_a_f); - - // Get quantized input B (either from pre-quantized int8 or by quantizing) - int8_t quantized_b; - if (input_b_is_int8) { - quantized_b = input_b_spec.get_int8_data()[b_idx]; - } else { - float quant_b_f = - std::round(input_b_spec.get_float_data()[b_idx] / input_b_scale) + - input_b_zero_point; - quant_b_f = std::min(std::max(quant_b_f, -128.0f), 127.0f); - quantized_b = static_cast(quant_b_f); - } - - // Dequantize both inputs to a common scale for addition - float dequant_a = - (static_cast(quantized_a) - input_a_zero_point) * input_a_scale; - float dequant_b = - (static_cast(quantized_b) - input_b_zero_point) * input_b_scale; - - // Perform addition in float space with alpha - float float_result = dequant_a + alpha * dequant_b; - - // Quantize the result to int8 - float quant_output_f = - std::round(float_result / output_scale) + output_zero_point; - quant_output_f = std::min(std::max(quant_output_f, -128.0f), 127.0f); - int8_t quantized_output = static_cast(quant_output_f); - - // Dequantize back to float for comparison - float dequant_output = - (static_cast(quantized_output) - output_zero_point) * - output_scale; - - ref_data[i] = dequant_output; - } -} - -} // namespace q8ta_binary -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8ta/binary/binary.h b/backends/vulkan/test/ops/benchmark/cases/q8ta/binary/binary.h deleted file mode 100644 index 5e5541397bf..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8ta/binary/binary.h +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#pragma once - -#include -#include - -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8ta_binary { - -constexpr int64_t kRefDimSizeLimit = 512; - -// Configuration struct for q8ta binary testing -struct Q8taBinaryConfig { - std::vector shape; // Tensor shape (can be any dimensionality) - std::string test_case_name = "placeholder"; - std::string op_name = "q8ta_add"; - // Shape of input B; empty means same as `shape`. Inputs are broadcast - // against each other. - std::vector other_shape = {}; -}; - -TestCase create_test_case_from_config( - const Q8taBinaryConfig& config, - utils::StorageType storage_type, - vkapi::ScalarType input_dtype, - utils::GPUMemoryLayout fp_memory_layout, - utils::GPUMemoryLayout quant_layout, - bool const_b = false); - -void q8ta_add_reference_impl(TestCase& test_case); - -} // namespace q8ta_binary -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8ta/binary/test_sets/binary_sets.cpp b/backends/vulkan/test/ops/benchmark/cases/q8ta/binary/test_sets/binary_sets.cpp deleted file mode 100644 index 6470f4f7373..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8ta/binary/test_sets/binary_sets.cpp +++ /dev/null @@ -1,168 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8ta_binary { - -namespace { - -std::vector generate_test_cases( - const std::vector>& shapes, - const std::vector, std::vector>>& - broadcast_shapes, - const std::vector& quant_layouts) { - std::vector test_cases; - - std::vector configs; - for (const auto& shape : shapes) { - // Generate test case name prefix from shape dimensions - std::string prefix = "ACCU"; - for (const auto& dim : shape) { - if (dim > kRefDimSizeLimit) { - prefix = "PERF"; - break; - } - } - - Q8taBinaryConfig config; - config.shape = shape; - config.test_case_name = prefix; - configs.push_back(config); - } - for (const auto& [shape, other_shape] : broadcast_shapes) { - Q8taBinaryConfig config; - config.shape = shape; - config.test_case_name = "ACCU"; - config.other_shape = other_shape; - configs.push_back(config); - } - - // Generate all combinations - for (const auto& config : configs) { - for (const auto& quant_layout : quant_layouts) { - test_cases.push_back(create_test_case_from_config( - config, - /*storage_type=*/utils::kBuffer, - /*input_dtype=*/vkapi::kFloat, - /*fp_memory_layout=*/utils::kWidthPacked, - quant_layout)); - test_cases.push_back(create_test_case_from_config( - config, - /*storage_type=*/utils::kBuffer, - /*input_dtype=*/vkapi::kFloat, - /*fp_memory_layout=*/utils::kWidthPacked, - quant_layout, - /*const_b=*/true)); - } - } - - return test_cases; -} - -} // namespace - -REGISTER_TEST_CASE_SET("q8ta/binary", "correctness") { - // Shapes to test - const std::vector> shapes = { - // 1D tensors - {144}, - {90}, - - // 3D tensors - {1, 16, 32}, - {1, 3, 64}, - - // 2D tensors (exercises block config with ndim < 4) - {1, 144}, - {1, 90}, - {1, 4}, - {3, 32}, - - // Small test cases for correctness - {1, 3, 16, 16}, - {1, 8, 32, 32}, - {1, 16, 24, 24}, - {1, 32, 12, 12}, - {1, 1, 64, 64}, - {1, 3, 64, 64}, - {1, 4, 16, 16}, - - // Different tensor sizes - {1, 8, 20, 20}, - {1, 16, 14, 14}, - {1, 8, 28, 28}, - - // Odd tensor sizes - {1, 3, 15, 15}, - {1, 13, 31, 31}, - {1, 17, 23, 23}, - - // Performance test cases (larger tensors) - {1, 64, 128, 128}, - {1, 32, 64, 64}, - {1, 128, 56, 56}, - {1, 128, 128, 128}, - }; - // Quantized memory layouts to test - const std::vector quant_layouts = { - utils::kPackedInt8_4W, - utils::kPackedInt8_4C, - utils::kPackedInt8_4W4C, - utils::kPackedInt8_4H4W, - utils::kPackedInt8_4C1W, - }; - // Broadcast cases: {input A shape, input B shape} - const std::vector, std::vector>> - broadcast_shapes = { - // Per-video context row added to every frame row - {{1, 60, 512}, {1, 1, 512}}, - {{1, 1, 512}, {1, 60, 512}}, - {{1, 16, 32}, {1, 16, 1}}, - {{1, 16, 32}, {32}}, - {{1, 8, 16, 16}, {1, 8, 1, 1}}, - {{1, 8, 16, 16}, {1, 1, 16, 16}}, - {{2, 8, 6, 6}, {1, 8, 6, 6}}, - {{1, 13, 7, 9}, {1, 1, 7, 1}}, - }; - return { - generate_test_cases(shapes, broadcast_shapes, quant_layouts), - q8ta_add_reference_impl}; -} - -// Easy test cases for q8ta_add operation (for debugging) -REGISTER_TEST_CASE_SET("q8ta/binary", "debug") { - const std::vector> shapes = { - {1, 16, 16, 16}, // 4D: [N, C, H, W] - {1, 144}, // 2D: exercises block config with ndim < 4 - {1, 90}, // 2D: matches skin_seg model's keypoint/bbox tensor sizes - }; - // Quantized memory layouts to test - const std::vector quant_layouts = { - utils::kPackedInt8_4W, - utils::kPackedInt8_4C, - utils::kPackedInt8_4W4C, - utils::kPackedInt8_4H4W, - utils::kPackedInt8_4C1W, - }; - const std::vector, std::vector>> - broadcast_shapes = {}; - return { - generate_test_cases(shapes, broadcast_shapes, quant_layouts), - q8ta_add_reference_impl}; -} - -} // namespace q8ta_binary -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8ta/clone/clone.cpp b/backends/vulkan/test/ops/benchmark/cases/q8ta/clone/clone.cpp deleted file mode 100644 index 7ed1363695c..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8ta/clone/clone.cpp +++ /dev/null @@ -1,187 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include -#include -#include -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8ta_clone { - -// Utility function to create a test case from a Q8taCloneConfig -TestCase create_test_case_from_config( - const Q8taCloneConfig& config, - utils::StorageType storage_type, - vkapi::ScalarType input_dtype, - utils::GPUMemoryLayout fp_memory_layout, - utils::GPUMemoryLayout inp_quant_layout, - utils::GPUMemoryLayout outp_quant_layout) { - TestCase test_case; - - // Create a descriptive name for the test case - // Q-clone-DQ: fp input transitions through int8 layouts and back to fp. - // Label dtype-wise as i8->i8 since the clone happens in the quantized domain. - std::string prefix = config.test_case_name; // "ACCU" or "PERF" - std::string storage_str = repr_str(storage_type, fp_memory_layout) + "->" + - repr_str(utils::kBuffer, inp_quant_layout) + "->" + - repr_str(utils::kBuffer, outp_quant_layout); - std::string test_name = make_test_label( - prefix, - dtype_short(vkapi::kChar), - dtype_short(vkapi::kChar), - shape_bracket(config.shape), - storage_str); - test_case.set_name(test_name); - - // Set the operator name for the test case - std::string operator_name = "test_etvk." + config.op_name + ".default"; - test_case.set_operator_name(operator_name); - - // Input tensor (float) - any dimensionality - ValueSpec input_tensor( - config.shape, - input_dtype, - storage_type, - fp_memory_layout, - DataGenType::RANDOM); - - float scale_val = 0.007112; - ValueSpec scale(scale_val); - - // Zero point for quantization - int32_t zero_point_val = 0; - ValueSpec zero_point(zero_point_val); - - // Input and output quantized layouts as integers - int32_t inp_layout_int = static_cast(inp_quant_layout); - ValueSpec inp_layout_spec(inp_layout_int); - - int32_t outp_layout_int = static_cast(outp_quant_layout); - ValueSpec outp_layout_spec(outp_layout_int); - - // Output tensor (float) - same shape as input - ValueSpec output_tensor( - config.shape, - input_dtype, - storage_type, - fp_memory_layout, - DataGenType::ZEROS); - - // Add all specs to test case - test_case.add_input_spec(input_tensor); - test_case.add_input_spec(scale); - test_case.add_input_spec(zero_point); - test_case.add_input_spec(inp_layout_spec); - test_case.add_input_spec(outp_layout_spec); - test_case.add_output_spec(output_tensor); - - test_case.set_abs_tolerance(scale_val + 1e-4); - - // Use layout-only filter for this test since clone IS the operation being - // tested - test_case.set_shader_filter({ - "nchw_to", - "to_nchw", - "q8ta_quantize", - "q8ta_dequantize", - }); - - return test_case; -} - -// Reference implementation for q8ta_clone operation -// Since clone just copies data, the result should be the same as -// quantize-dequantize -void q8ta_clone_reference_impl(TestCase& test_case) { - int32_t idx = 0; - const ValueSpec& input_spec = test_case.inputs()[idx++]; - const ValueSpec& scale_spec = test_case.inputs()[idx++]; - const ValueSpec& zero_point_spec = test_case.inputs()[idx++]; - const ValueSpec& inp_layout_spec = test_case.inputs()[idx++]; - const ValueSpec& outp_layout_spec = test_case.inputs()[idx++]; - (void)inp_layout_spec; // Not used in reference implementation - (void)outp_layout_spec; // Not used in reference implementation - - // Extract output specification - ValueSpec& output_spec = test_case.outputs()[0]; - - // Get tensor dimensions (arbitrary dimensionality) - auto input_sizes = input_spec.get_tensor_sizes(); - - // Calculate total number of elements - int64_t num_elements = 1; - for (const auto& dim : input_sizes) { - num_elements *= dim; - } - - // Skip for large tensors since computation time will be extremely slow - for (const auto& dim : input_sizes) { - if (dim > kRefDimSizeLimit) { - throw std::invalid_argument( - "One or more dimensions exceed the allowed limit for reference " - "implementation."); - } - } - - if (input_spec.dtype != vkapi::kFloat) { - throw std::invalid_argument("Unsupported dtype"); - } - - // Get raw data pointers - auto& input_data = input_spec.get_float_data(); - - // Extract the randomized scale and zero point values - float scale = scale_spec.get_float_value(); - int32_t zero_point = zero_point_spec.get_int_value(); - int32_t quant_min = -128; - int32_t quant_max = 127; - - // Prepare output data - auto& ref_data = output_spec.get_ref_float_data(); - ref_data.resize(num_elements); - - // Perform quantize-clone-dequantize operation on each element - // Clone preserves the quantized values, so result is same as Q-DQ - for (int64_t i = 0; i < num_elements; ++i) { - float input_val = input_data[i]; - - // Quantize: quantized = round(input / scale + zero_point) - float quantized_float = std::round(input_val / scale) + zero_point; - - // Clamp to quantization range - quantized_float = std::max(quantized_float, static_cast(quant_min)); - quantized_float = std::min(quantized_float, static_cast(quant_max)); - - int32_t quantized_int = static_cast(quantized_float); - - // Dequantize: output = (quantized - zero_point) * scale - float dequantized = (quantized_int - zero_point) * scale; - - ref_data[i] = dequantized; - } -} - -std::string get_prefix(const std::vector& shape) { - std::string prefix = "ACCU"; - for (const auto& dim : shape) { - if (dim > kRefDimSizeLimit) { - prefix = "PERF"; - break; - } - } - return prefix; -} - -} // namespace q8ta_clone -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8ta/clone/clone.h b/backends/vulkan/test/ops/benchmark/cases/q8ta/clone/clone.h deleted file mode 100644 index 2e0677fd646..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8ta/clone/clone.h +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#pragma once - -#include -#include - -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8ta_clone { - -constexpr int64_t kRefDimSizeLimit = 512; - -// Configuration struct for q8ta clone testing -struct Q8taCloneConfig { - std::vector shape; // Tensor shape (can be any dimensionality) - std::string test_case_name = "placeholder"; - std::string op_name = "q8ta_clone_test"; -}; - -TestCase create_test_case_from_config( - const Q8taCloneConfig& config, - utils::StorageType storage_type, - vkapi::ScalarType input_dtype, - utils::GPUMemoryLayout fp_memory_layout, - utils::GPUMemoryLayout inp_quant_layout, - utils::GPUMemoryLayout outp_quant_layout); - -void q8ta_clone_reference_impl(TestCase& test_case); - -// "PERF" if any dim of shape exceeds kRefDimSizeLimit, else "ACCU". -std::string get_prefix(const std::vector& shape); - -} // namespace q8ta_clone -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8ta/clone/test_sets/correctness_set.cpp b/backends/vulkan/test/ops/benchmark/cases/q8ta/clone/test_sets/correctness_set.cpp deleted file mode 100644 index 509f249672b..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8ta/clone/test_sets/correctness_set.cpp +++ /dev/null @@ -1,107 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8ta_clone { - -namespace { - -std::vector generate_test_cases( - const std::vector>& shapes, - const std::vector& fp_layouts, - const std::vector& quant_layouts, - const std::vector& storage_types) { - std::vector test_cases; - - // Generate all combinations - for (const auto& shape : shapes) { - std::string prefix = get_prefix(shape); - - for (const auto& fp_layout : fp_layouts) { - for (const auto& inp_quant_layout : quant_layouts) { - for (const auto& outp_quant_layout : quant_layouts) { - for (const auto& storage_type : storage_types) { - Q8taCloneConfig config; - config.shape = shape; - config.test_case_name = prefix; - - test_cases.push_back(create_test_case_from_config( - config, - storage_type, - vkapi::kFloat, - fp_layout, - inp_quant_layout, - outp_quant_layout)); - } - } - } - } - } - - return test_cases; -} - -} // namespace - -REGISTER_TEST_CASE_SET("q8ta/clone", "correctness") { - // Shapes to test - const std::vector> shapes = { - // Small test cases for correctness - {1, 3, 16, 16}, - {1, 8, 32, 32}, - {1, 16, 24, 24}, - {1, 32, 12, 12}, - {1, 1, 64, 64}, - {1, 3, 64, 64}, - {1, 4, 16, 16}, - - // Different tensor sizes - {1, 8, 20, 20}, - {1, 16, 14, 14}, - {1, 8, 28, 28}, - - // Odd tensor sizes - {1, 3, 15, 15}, - {1, 13, 31, 31}, - {1, 17, 23, 23}, - - // Performance test cases (larger tensors) - {1, 64, 128, 128}, - {1, 32, 64, 64}, - {1, 128, 56, 56}, - {1, 128, 128, 128}, - }; - // FP memory layouts to test - const std::vector fp_layouts = { - utils::kWidthPacked, - utils::kChannelsPacked, - }; - // Quantized memory layouts to test - const std::vector quant_layouts = { - utils::kPackedInt8_4W, - utils::kPackedInt8_4C, - utils::kPackedInt8_4W4C, - utils::kPackedInt8_4H4W, - utils::kPackedInt8_4C1W, - }; - // Test with buffer storage only - const std::vector storage_types = {utils::kBuffer}; - return { - generate_test_cases(shapes, fp_layouts, quant_layouts, storage_types), - q8ta_clone_reference_impl}; -} - -} // namespace q8ta_clone -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8ta/clone/test_sets/debug_set.cpp b/backends/vulkan/test/ops/benchmark/cases/q8ta/clone/test_sets/debug_set.cpp deleted file mode 100644 index 97f869ab02e..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8ta/clone/test_sets/debug_set.cpp +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8ta_clone { - -namespace { - -std::vector generate_same_layout_test_cases( - const std::vector>& shapes, - const std::vector& fp_layouts, - const std::vector& quant_layouts, - const std::vector& storage_types, - const std::vector& float_types) { - std::vector test_cases; - - for (const auto& shape : shapes) { - Q8taCloneConfig config = {shape, get_prefix(shape)}; - - // Generate test cases for each combination (same layout for input and - // output) - for (const auto& fp_layout : fp_layouts) { - for (const auto& quant_layout : quant_layouts) { - for (const auto& storage_type : storage_types) { - for (const auto& input_dtype : float_types) { - // Same layout: should be a simple copy - test_cases.push_back(create_test_case_from_config( - config, - storage_type, - input_dtype, - fp_layout, - quant_layout, - quant_layout)); - } - } - } - } - } - - return test_cases; -} - -} // namespace - -// Easy test cases for q8ta_clone operation (for debugging) -REGISTER_TEST_CASE_SET("q8ta/clone", "debug") { - // Single simple configuration for debugging - const std::vector> shapes = { - {1, 16, 16, 16}, // shape: [N, C, H, W] - }; - // FP memory layouts to test - const std::vector fp_layouts = { - utils::kWidthPacked, - utils::kChannelsPacked, - }; - // Quantized memory layouts to test - const std::vector quant_layouts = { - utils::kPackedInt8_4W, - utils::kPackedInt8_4C, - utils::kPackedInt8_4W4C, - utils::kPackedInt8_4H4W, - utils::kPackedInt8_4C1W, - }; - const std::vector storage_types = {utils::kBuffer}; - const std::vector float_types = {vkapi::kFloat}; - return { - generate_same_layout_test_cases( - shapes, fp_layouts, quant_layouts, storage_types, float_types), - q8ta_clone_reference_impl}; -} - -} // namespace q8ta_clone -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/conv2d.cpp b/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/conv2d.cpp deleted file mode 100644 index cd80172a3d3..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/conv2d.cpp +++ /dev/null @@ -1,605 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include -#include -#include -#include -#include -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8ta_conv2d { - -namespace { - -// Utility function to create a test case from a Conv2dConfig -TestCase create_test_case_from_config_with_layouts( - const Conv2dConfig& config, - vkapi::ScalarType input_dtype, - utils::StorageType fp_storage_type, - utils::GPUMemoryLayout input_int8_memory_layout, - utils::GPUMemoryLayout output_int8_memory_layout, - const std::string& impl_selector = "", - const Im2colUnsignedTestOptions* im2col_options = nullptr, - const float input_scale_val = 0.008123f, - const DataGenType input_data_gen = DataGenType::RANDOM) { - TestCase test_case; - - // Calculate output dimensions - int64_t H_out = config.get_output_height(); - int64_t W_out = config.get_output_width(); - - // Input tensor (float/half) - [N, C_in, H_in, W_in] - std::vector input_size = { - config.batch, - config.channels.in, - config.input_size.h, - config.input_size.w}; - - utils::GPUMemoryLayout fp_memory_layout = fp_storage_type == utils::kBuffer - ? utils::kWidthPacked - : utils::kChannelsPacked; - - // Create test case name - std::string prefix = config.test_case_name.substr(0, 4); // "ACCU" or "PERF" - std::string dtype_str = dtype_short(input_dtype); - std::string in_shape = "[" + std::to_string(config.batch) + "," + - std::to_string(config.channels.in) + "," + - std::to_string(config.input_size.h) + "," + - std::to_string(config.input_size.w) + "]"; - std::string weight_shape = "[" + std::to_string(config.channels.out) + "," + - std::to_string(config.channels.in / config.groups) + "," + - std::to_string(config.kernel.h) + "," + std::to_string(config.kernel.w) + - "]"; - std::string shape_str = in_shape + "x" + weight_shape + " s" + - std::to_string(config.stride.h) + " p" + - std::to_string(config.padding.h) + " d" + - std::to_string(config.dilation.h) + " g" + std::to_string(config.groups); - std::string storage_str = repr_str(utils::kBuffer, input_int8_memory_layout); - if (input_int8_memory_layout != output_int8_memory_layout) { - storage_str += "->" + repr_str(utils::kBuffer, output_int8_memory_layout); - } - std::string suffix = impl_selector.empty() ? "" : "[" + impl_selector + "]"; - std::string test_name = make_test_label( - prefix, dtype_str, dtype_str, shape_str, storage_str, suffix); - test_case.set_name(test_name); - - // Set the operator name for the test case - use the unified test operator - std::string operator_name = "test_etvk.test_q8ta_conv2d.default"; - test_case.set_operator_name(operator_name); - - ValueSpec input_tensor( - input_size, - input_dtype, - fp_storage_type, - fp_memory_layout, - input_data_gen); - - if (debugging()) { - print_valuespec_data(input_tensor, "input_tensor"); - } - - ValueSpec input_scale(input_scale_val); - - const int32_t input_zero_point_val = - im2col_options == nullptr ? 2 : im2col_options->input_zero_point; - ValueSpec input_zero_point(input_zero_point_val); - - if (im2col_options != nullptr && - im2col_options->use_accumulator_limit_values) { - input_tensor.ensure_data_generated(2401); - std::fill( - input_tensor.get_float_data().begin(), - input_tensor.get_float_data().end(), - (127.0f - input_zero_point_val) * input_scale_val); - } else if (im2col_options != nullptr && im2col_options->use_extreme_values) { - input_tensor.ensure_data_generated(2401); - constexpr std::array values = {-128, -1, 0, 1, 127}; - std::vector& input_data = input_tensor.get_float_data(); - for (size_t i = 0; i < input_data.size(); ++i) { - input_data.at(i) = (static_cast(values.at(i % values.size())) - - input_zero_point_val) * - input_scale_val; - } - } - - // Quantized weight tensor (int8) - [C_out, C_in_per_group * K_h * K_w] - // Memory layout: height, width, then channels - in_c is innermost (stride 1) - // in the second dimension - const int64_t in_channels_per_group = config.channels.in / config.groups; - const int64_t in_features = utils::align_up_4( - in_channels_per_group * config.kernel.h * config.kernel.w); - std::vector weight_size = {config.channels.out, in_features}; - ValueSpec quantized_weight( - weight_size, - vkapi::kChar, // int8 for quantized weights - fp_storage_type, - utils::kWidthPacked, - DataGenType::RANDINT8); - quantized_weight.set_constant(true); - - if (im2col_options != nullptr && - im2col_options->use_accumulator_limit_values) { - quantized_weight.ensure_data_generated(2402); - std::fill( - quantized_weight.get_int8_data().begin(), - quantized_weight.get_int8_data().end(), - 127); - } else if (im2col_options != nullptr && im2col_options->use_extreme_values) { - quantized_weight.ensure_data_generated(2402); - constexpr std::array values = {-128, -1, 0, 1, 127}; - std::vector& weight_data = quantized_weight.get_int8_data(); - for (size_t i = 0; i < weight_data.size(); ++i) { - weight_data.at(i) = values.at((i * 3 + 1) % values.size()); - } - } - - if (debugging()) { - print_valuespec_data(quantized_weight, "weight_tensor"); - } - - const int64_t aligned_out_channels = utils::align_up_4(config.channels.out); - - // Weight quantization scales (float/half, per-channel) - ValueSpec weight_scales( - {aligned_out_channels}, // Per output channel - input_dtype, - fp_storage_type, - utils::kWidthPacked, - DataGenType::RANDOM_SCALES); - weight_scales.set_constant(true); - if (im2col_options != nullptr) { - weight_scales.ensure_data_generated(2403); - std::fill( - weight_scales.get_float_data().begin(), - weight_scales.get_float_data().end(), - im2col_options->weight_scale); - } - - ValueSpec weight_sums( - {aligned_out_channels}, // Per output channel - vkapi::kInt, - fp_storage_type, - utils::kWidthPacked, - DataGenType::ZEROS); - weight_sums.set_constant(true); - - // Compute weight_sums data based on quantized weights - compute_weight_sums( - weight_sums, quantized_weight, config.channels.out, in_features); - - // Bias (optional, float/half) - [C_out] - ValueSpec bias( - {aligned_out_channels}, // Per output channel - input_dtype, - fp_storage_type, - utils::kWidthPacked, - DataGenType::ZEROS); - bias.set_constant(true); - if (im2col_options != nullptr && !im2col_options->has_bias) { - bias.set_none(true); - } - - // Output quantization parameters - float output_scale_val = 0.05314; - ValueSpec output_scale(output_scale_val); - - const int32_t output_zero_point_val = - im2col_options == nullptr ? -1 : im2col_options->output_zero_point; - ValueSpec output_zero_point(output_zero_point_val); - - // Stride and padding parameters - ValueSpec stride({config.stride.h, config.stride.w}); - ValueSpec padding({config.padding.h, config.padding.w}); - - // Dilation and groups parameters - ValueSpec dilation({config.dilation.h, config.dilation.w}); - ValueSpec groups(config.groups); - - // Kernel size parameters - ValueSpec kernel_size({config.kernel.h, config.kernel.w}); - - // Output tensor (float/half) - [N, C_out, H_out, W_out] - ValueSpec output( - {config.batch, config.channels.out, H_out, W_out}, - input_dtype, - fp_storage_type, - fp_memory_layout, - DataGenType::ZEROS); - - // Add all specs to test case for q8ta_q8csw_q8to operation - test_case.add_input_spec(input_tensor); - test_case.add_input_spec(input_scale); - test_case.add_input_spec(input_zero_point); - test_case.add_input_spec(quantized_weight); - test_case.add_input_spec(weight_sums); - test_case.add_input_spec(weight_scales); - test_case.add_input_spec(output_scale); - test_case.add_input_spec(output_zero_point); - test_case.add_input_spec(bias); - test_case.add_input_spec(kernel_size); - test_case.add_input_spec(stride); - test_case.add_input_spec(padding); - test_case.add_input_spec(dilation); - test_case.add_input_spec(groups); - - // Activation (none = no activation) - ValueSpec activation = ValueSpec::make_string( - im2col_options == nullptr ? "none" : im2col_options->activation); - test_case.add_input_spec(activation); - - // Add memory layout parameter for the quantized tensors - ValueSpec input_layout_int(static_cast(input_int8_memory_layout)); - test_case.add_input_spec(input_layout_int); - - ValueSpec output_layout_int(static_cast(output_int8_memory_layout)); - test_case.add_input_spec(output_layout_int); - - // Add impl_selector string - ValueSpec impl_selector_spec = ValueSpec::make_string(impl_selector); - test_case.add_input_spec(impl_selector_spec); - - test_case.add_output_spec(output); - - test_case.set_abs_tolerance( - im2col_options == nullptr ? output_scale_val + 1e-4f - : output_scale_val * 0.25f); - - // Filter out quantize/dequantize shaders from timing measurements - test_case.set_shader_filter({ - "nchw_to", - "to_nchw", - "q8ta_quantize", - "q8ta_dequantize", - }); - - return test_case; -} - -} // namespace - -TestCase create_test_case_from_config( - const Conv2dConfig& config, - vkapi::ScalarType input_dtype, - utils::StorageType fp_storage_type, - utils::GPUMemoryLayout int8_memory_layout, - const std::string& impl_selector, - const Im2colUnsignedTestOptions* im2col_options, - const float input_scale_val, - const DataGenType input_data_gen) { - return create_test_case_from_config_with_layouts( - config, - input_dtype, - fp_storage_type, - int8_memory_layout, - int8_memory_layout, - impl_selector, - im2col_options, - input_scale_val, - input_data_gen); -} - -// SceneX route tests. The kPackedInt8_4C input + kPackedInt8_4W4C output -// layout combination is only exercised here (default generators never pair -// them); run via the scenex_regular and scenex_grouped sets. Zero -// tolerances are exact by construction: both pipelines accumulate in int32 -// with identical requantize, so any mismatch is a real regression, not noise. -TestCase create_scenex_test_case( - const Conv2dConfig& source_config, - const std::string& route) { - Conv2dConfig config = source_config; - config.op_name = "conv2d_q8ta_q8csw_q8to"; - config.test_case_name = - make_test_case_name(config, false, utils::kTexture3D, utils::kBuffer); - - const std::string impl_selector = route == "auto" ? "" - : route == "direct" ? "general" - : "im2col"; - TestCase test_case = create_test_case_from_config_with_layouts( - config, - vkapi::kFloat, - utils::kTexture3D, - utils::kPackedInt8_4C, - utils::kPackedInt8_4W4C, - impl_selector, - /*im2col_options=*/nullptr, - /*input_scale_val=*/1.0f, - /*input_data_gen=*/DataGenType::RANDINT); - test_case.set_abs_tolerance(0.0f); - test_case.set_rel_tolerance(0.0f); - return test_case; -} - -// Custom FLOP calculator for quantized conv2d operation -int64_t quantized_conv2d_flop_calculator(const TestCase& test_case) { - int kernel_idx = 9; // kernel_size is at index 9 for q8ta_q8csw_q8to - - // Get input and weight dimensions - const auto& input_sizes = test_case.inputs()[0].get_tensor_sizes(); - const auto& output_sizes = test_case.outputs()[0].get_tensor_sizes(); - - const auto& kernel_sizes = test_case.inputs()[kernel_idx].get_int32_data(); - - int64_t N = input_sizes[0]; - int64_t C_in = input_sizes[1]; - int64_t C_out = output_sizes[1]; - int64_t K_h = kernel_sizes[0]; - int64_t K_w = kernel_sizes[1]; - int64_t H_out = output_sizes[2]; - int64_t W_out = output_sizes[3]; - - // Calculate FLOPs for quantized conv2d operation - // Each output element requires: - // - C_in * K_h * K_w multiply-accumulate operations - // - Additional operations for quantization/dequantization - int64_t output_elements = N * C_out * H_out * W_out; - int64_t ops_per_output = C_in * K_h * K_w; - - int64_t flop = output_elements * (ops_per_output); - - return flop; -} - -namespace { - -// Reference implementation for activation, weight, and output quantized conv2d -void conv2d_q8ta_q8csw_q8to_reference_impl(TestCase& test_case) { - // Extract input specifications - int32_t idx = 0; - const ValueSpec& input_spec = test_case.inputs()[idx++]; - const ValueSpec& input_scale_spec = test_case.inputs()[idx++]; - const ValueSpec& input_zeros_spec = test_case.inputs()[idx++]; - const ValueSpec& weight_spec = test_case.inputs()[idx++]; - const ValueSpec& weight_sums_spec = test_case.inputs()[idx++]; - (void)weight_sums_spec; - const ValueSpec& weight_scales_spec = test_case.inputs()[idx++]; - const ValueSpec& output_scale_spec = test_case.inputs()[idx++]; - const ValueSpec& output_zeros_spec = test_case.inputs()[idx++]; - const ValueSpec& bias_spec = test_case.inputs()[idx++]; - const ValueSpec& kernel_size_spec = test_case.inputs()[idx++]; - const ValueSpec& stride_spec = test_case.inputs()[idx++]; - const ValueSpec& padding_spec = test_case.inputs()[idx++]; - const ValueSpec& dilation_spec = test_case.inputs()[idx++]; - const ValueSpec& groups_spec = test_case.inputs()[idx++]; - const ValueSpec& activation_spec = test_case.inputs()[idx++]; - const ValueSpec& layout_spec = test_case.inputs()[idx++]; - (void)layout_spec; // Not used in reference implementation - const ValueSpec& output_layout_spec = test_case.inputs()[idx++]; - (void)output_layout_spec; // Not used in reference implementation - const ValueSpec& impl_selector_spec = test_case.inputs()[idx++]; - (void)impl_selector_spec; // Not used in reference implementation - - // Extract output specification (mutable reference) - ValueSpec& output_spec = test_case.outputs()[0]; - - // Get tensor dimensions - auto input_sizes = input_spec.get_tensor_sizes(); // [N, C_in, H_in, W_in] - auto weight_sizes = - weight_spec.get_tensor_sizes(); // [C_out, C_in_per_group * K_h * K_w] - auto output_sizes = - output_spec.get_tensor_sizes(); // [N, C_out, H_out, W_out] - - int64_t N = input_sizes[0]; - int64_t C_in = input_sizes[1]; - int64_t H_in = input_sizes[2]; - int64_t W_in = input_sizes[3]; - int64_t C_out = output_sizes[1]; - int64_t H_out = output_sizes[2]; - int64_t W_out = output_sizes[3]; - - // Get kernel dimensions from kernel_size ValueSpec - auto kernel_size_data = kernel_size_spec.get_int32_data(); - int64_t K_h = kernel_size_data[0]; - int64_t K_w = kernel_size_data[1]; - - // Get stride, padding, dilation, and groups - auto stride_data = stride_spec.get_int32_data(); - auto padding_data = padding_spec.get_int32_data(); - auto dilation_data = dilation_spec.get_int32_data(); - int64_t stride_h = stride_data[0]; - int64_t stride_w = stride_data[1]; - int64_t pad_h = padding_data[0]; - int64_t pad_w = padding_data[1]; - int64_t dilation_h = dilation_data[0]; - int64_t dilation_w = dilation_data[1]; - int64_t groups = groups_spec.get_int_value(); - - const int64_t reference_operations = - N * C_out * H_out * W_out * (C_in / groups) * K_h * K_w; - const bool has_large_dimension = N > kRefDimSizeLimit || - C_in > kRefDimSizeLimit || H_in > kRefDimSizeLimit || - W_in > kRefDimSizeLimit || C_out > kRefDimSizeLimit; - if (has_large_dimension && reference_operations > kRefOperationLimit) { - throw std::invalid_argument( - "One or more dimensions exceed the allowed limit for reference implementation."); - std::cout - << "Reference implementation: computation may take some time for large tensors..." - << std::endl; - } - - if (input_spec.dtype != vkapi::kFloat) { - throw std::invalid_argument("Unsupported dtype"); - } - - // Get raw data pointers - auto& input_data = input_spec.get_float_data(); - const float input_scale = input_scale_spec.get_float_value(); - const int32_t input_zero_point = input_zeros_spec.get_int_value(); - - auto& weight_data = weight_spec.get_int8_data(); - auto& weight_scales_data = weight_scales_spec.get_float_data(); - auto& bias_data = bias_spec.get_float_data(); - - const float output_scale = output_scale_spec.get_float_value(); - const int32_t output_zero_point = output_zeros_spec.get_int_value(); - - // Calculate channels per group for grouped convolution - int64_t C_in_per_group = C_in / groups; - int64_t C_out_per_group = C_out / groups; - - // Calculate number of output elements - int64_t num_output_elements = N * C_out * H_out * W_out; - - auto& ref_data = output_spec.get_ref_float_data(); - ref_data.resize(num_output_elements); - - const int64_t in_features = utils::align_up_4(C_in_per_group * K_h * K_w); - - // Perform activation, weight, and output quantized conv2d operation - for (int64_t n = 0; n < N; ++n) { - for (int64_t out_c = 0; out_c < C_out; ++out_c) { - for (int64_t out_h = 0; out_h < H_out; ++out_h) { - for (int64_t out_w = 0; out_w < W_out; ++out_w) { - int32_t int_sum = 0; - int32_t weight_sum = 0; // Track weight sum on the fly - - // Determine which group this output channel belongs to - int64_t group_idx = out_c / C_out_per_group; - int64_t in_c_start = group_idx * C_in_per_group; - int64_t in_c_end = (group_idx + 1) * C_in_per_group; - - // Convolution operation with integer accumulation - for (int64_t in_c = in_c_start; in_c < in_c_end; ++in_c) { - for (int64_t kh = 0; kh < K_h; ++kh) { - for (int64_t kw = 0; kw < K_w; ++kw) { - // Calculate input position with dilation - int64_t in_h = out_h * stride_h - pad_h + kh * dilation_h; - int64_t in_w = out_w * stride_w - pad_w + kw * dilation_w; - - // Check bounds (zero padding) - if (in_h >= 0 && in_h < H_in && in_w >= 0 && in_w < W_in) { - // Get input value and quantize to int8 - int64_t input_idx = n * (C_in * H_in * W_in) + - in_c * (H_in * W_in) + in_h * W_in + in_w; - - float quant_input_f = - std::round(input_data[input_idx] / input_scale) + - input_zero_point; - quant_input_f = - std::min(std::max(quant_input_f, -128.0f), 127.0f); - int8_t quantized_input = static_cast(quant_input_f); - - // Get quantized weight (already int8) - // Weight layout: [C_out, C_in_per_group * K_h * K_w] - int64_t weight_idx = out_c * in_features + - (kh * (K_w * C_in_per_group) + kw * C_in_per_group + - (in_c % C_in_per_group)); - int8_t quantized_weight = weight_data[weight_idx]; - - // Integer multiplication and accumulation - int_sum += static_cast(quantized_input) * - static_cast(quantized_weight); - - // Track weight sum for this output channel on the fly - weight_sum += static_cast(quantized_weight); - } else { - // For zero padding, we still need to account for the weight - // in weight_sum when input is effectively 0 (but quantized 0 - // is input_zero_point) - int64_t weight_idx = out_c * in_features + - (kh * (K_w * C_in_per_group) + kw * C_in_per_group + - (in_c % C_in_per_group)); - int8_t quantized_weight = weight_data[weight_idx]; - - // Add contribution from zero-padded input (quantized zero = - // input_zero_point) - int_sum += static_cast(input_zero_point) * - static_cast(quantized_weight); - - // Track weight sum for this output channel on the fly - weight_sum += static_cast(quantized_weight); - } - } - } - } - - // Convert accumulated integer result to float and apply scales - // Final result = (int_sum - zero_point_correction) * input_scale * - // weight_scale + bias zero_point_correction = input_zero_point * - // sum_of_weights_for_this_output_channel - int32_t zero_point_correction = input_zero_point * weight_sum; - int32_t accum_adjusted = int_sum - zero_point_correction; - float float_result = - accum_adjusted * input_scale * weight_scales_data[out_c]; - - if (!bias_spec.is_none()) { - float_result += bias_data[out_c]; - } - if (activation_spec.get_string_value() == "relu") { - float_result = std::max(float_result, 0.0f); - } - - // Quantize the output to int8 - float quant_output_f = - std::round(float_result / output_scale) + output_zero_point; - quant_output_f = std::min(std::max(quant_output_f, -128.0f), 127.0f); - int8_t quantized_output = static_cast(quant_output_f); - - // Dequantize back to float - float dequant_output = - (static_cast(quantized_output) - output_zero_point) * - output_scale; - - int64_t output_idx = n * (C_out * H_out * W_out) + - out_c * (H_out * W_out) + out_h * W_out + out_w; - ref_data[output_idx] = dequant_output; - } - } - } - } -} - -} // namespace - -void reference_impl(TestCase& test_case) { - conv2d_q8ta_q8csw_q8to_reference_impl(test_case); -} - -// The impl selector holds one of "", "general", or "im2col"; activation and -// other string inputs use disjoint values. Overwrite it by value so a -// reordered input list fails loudly instead of mutating the wrong spec. -// Note: for route == "direct" the measured run already forces "general", so -// this reference re-executes the identical implementation and only checks -// determinism; genuine cross-implementation correctness comes from the -// auto/im2col legs. -void scenex_direct_reference(TestCase& test_case) { - TestCase direct_case = test_case; - bool found_selector = false; - for (auto it = direct_case.inputs().rbegin(); - it != direct_case.inputs().rend(); - ++it) { - if (it->is_string() && - (it->get_string_value().empty() || - it->get_string_value() == "general" || - it->get_string_value() == "im2col")) { - it->string_data = "general"; - found_selector = true; - break; - } - } - if (!found_selector) { - throw std::runtime_error("scenex reference: impl selector input not found"); - } - execute_test_case( - direct_case, - /*warmup_runs=*/1, - /*benchmark_runs=*/1, - /*chained_dispatches=*/1, - /*write_outputs=*/true); - test_case.outputs().at(0).get_ref_float_data() = - direct_case.outputs().at(0).get_float_data(); -} - -} // namespace q8ta_conv2d -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/conv2d.h b/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/conv2d.h deleted file mode 100644 index 7d87b00f63f..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/conv2d.h +++ /dev/null @@ -1,81 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#pragma once - -#include -#include - -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8ta_conv2d { - -constexpr int64_t kRefDimSizeLimit = 100; -constexpr int64_t kRefOperationLimit = 2 * 1024 * 1024; - -struct Im2colUnsignedTestOptions { - int32_t input_zero_point = 2; - int32_t output_zero_point = -1; - bool use_extreme_values = false; - bool use_accumulator_limit_values = false; - bool has_bias = true; - const char* activation = "none"; - float weight_scale = 1.0f / 256.0f; -}; - -struct PointwiseTestOptions { - int32_t input_zero_point = 2; - bool has_bias = true; -}; - -TestCase create_test_case_from_config( - const Conv2dConfig& config, - vkapi::ScalarType input_dtype, - utils::StorageType fp_storage_type, - utils::GPUMemoryLayout int8_memory_layout, - const std::string& impl_selector = "", - const Im2colUnsignedTestOptions* im2col_options = nullptr, - const float input_scale_val = 0.008123f, - const DataGenType input_data_gen = DataGenType::RANDOM); - -TestCase create_scenex_test_case( - const Conv2dConfig& source_config, - const std::string& route); - -TestCase create_dw_test_case_from_config( - const Conv2dConfig& config, - vkapi::ScalarType input_dtype, - utils::StorageType fp_storage_type, - utils::GPUMemoryLayout int8_memory_layout, - const std::string& impl_selector = ""); - -TestCase create_pw_test_case_from_config( - const Conv2dConfig& config, - vkapi::ScalarType input_dtype, - utils::StorageType fp_storage_type, - utils::GPUMemoryLayout int8_memory_layout, - const std::string& impl_selector = "", - const PointwiseTestOptions& options = {}); - -void reference_impl(TestCase& test_case); - -void scenex_direct_reference(TestCase& test_case); - -void dw_reference_impl(TestCase& test_case); - -void pw_reference_impl(TestCase& test_case); - -int64_t quantized_conv2d_flop_calculator(const TestCase& test_case); - -int64_t quantized_conv2d_dw_flop_calculator(const TestCase& test_case); - -} // namespace q8ta_conv2d -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/conv2d_dw.cpp b/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/conv2d_dw.cpp deleted file mode 100644 index 8682971620d..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/conv2d_dw.cpp +++ /dev/null @@ -1,455 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include -#include -#include -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8ta_conv2d { - -// Utility function to create a test case from a Conv2dConfig for depthwise -// convolution -TestCase create_dw_test_case_from_config( - const Conv2dConfig& config, - vkapi::ScalarType input_dtype, - utils::StorageType fp_storage_type, - utils::GPUMemoryLayout int8_memory_layout, - const std::string& impl_selector) { - TestCase test_case; - - // Calculate output dimensions - int64_t H_out = config.get_output_height(); - int64_t W_out = config.get_output_width(); - - // Input tensor (float/half) - [N, C_in, H_in, W_in] - std::vector input_size = { - config.batch, - config.channels.in, - config.input_size.h, - config.input_size.w}; - - utils::GPUMemoryLayout fp_memory_layout = fp_storage_type == utils::kBuffer - ? utils::kWidthPacked - : utils::kChannelsPacked; - - // Create test case name - std::string prefix = config.test_case_name.substr(0, 4); // "ACCU" or "PERF" - std::string dtype_str = dtype_short(input_dtype); - std::string in_shape = "[" + std::to_string(config.batch) + "," + - std::to_string(config.channels.in) + "," + - std::to_string(config.input_size.h) + "," + - std::to_string(config.input_size.w) + "]"; - // depthwise: weight is [C_out, 1, K_h, K_w] - std::string weight_shape = "[" + std::to_string(config.channels.out) + ",1," + - std::to_string(config.kernel.h) + "," + std::to_string(config.kernel.w) + - "]"; - std::string shape_str = in_shape + "x" + weight_shape + " s" + - std::to_string(config.stride.h) + " p" + - std::to_string(config.padding.h) + " d" + - std::to_string(config.dilation.h) + " g" + std::to_string(config.groups); - std::string storage_str = repr_str(utils::kBuffer, int8_memory_layout); - std::string suffix = impl_selector.empty() ? "" : "[" + impl_selector + "]"; - std::string test_name = make_test_label( - prefix, dtype_str, dtype_str, shape_str, storage_str, suffix); - test_case.set_name(test_name); - - // Set the operator name for the test case - use the new unified test operator - std::string operator_name = "test_etvk.test_q8ta_conv2d_dw.default"; - test_case.set_operator_name(operator_name); - - ValueSpec input_tensor( - input_size, - input_dtype, - fp_storage_type, - fp_memory_layout, - DataGenType::RANDOM); - - if (debugging()) { - print_valuespec_data(input_tensor, "input_tensor", false, 64); - } - - float input_scale_val = 0.008123; - ValueSpec input_scale(input_scale_val); - - int32_t input_zero_point_val = 2; - ValueSpec input_zero_point(input_zero_point_val); - - // Quantized weight tensor (int8) for depthwise convolution - // Memory layout: [K_h, K_w, OC] - // For depthwise conv: groups = channels.out, in_channels_per_group = 1 - std::vector weight_size = { - config.kernel.h, config.kernel.w, config.channels.out}; - ValueSpec quantized_weight( - weight_size, - vkapi::kChar, // int8 for quantized weights - fp_storage_type, - utils::kWidthPacked, - DataGenType::RANDINT8); - quantized_weight.set_constant(true); - - if (debugging()) { - print_valuespec_data(quantized_weight, "weight_tensor", false, 64); - } - - // Weight quantization scales (float/half, per-channel) - ValueSpec weight_scales( - {config.channels.out}, // Per output channel - input_dtype, - fp_storage_type, - utils::kWidthPacked, - DataGenType::RANDOM_SCALES); - weight_scales.set_constant(true); - - ValueSpec weight_sums( - {config.channels.out}, // Per output channel - vkapi::kInt, - fp_storage_type, - utils::kWidthPacked, - DataGenType::ZEROS); - weight_sums.set_constant(true); - - // Compute weight_sums data based on quantized weights for depthwise layout - // For depthwise conv: each output channel has K_h * K_w weights - // Custom computation for depthwise layout [K_h, K_w, OC] - auto& weight_sums_data = weight_sums.get_int32_data(); - auto& quantized_weight_data = quantized_weight.get_int8_data(); - - weight_sums_data.resize(config.channels.out); - - for (int64_t out_c = 0; out_c < config.channels.out; ++out_c) { - int32_t sum = 0; - for (int64_t kh = 0; kh < config.kernel.h; ++kh) { - for (int64_t kw = 0; kw < config.kernel.w; ++kw) { - // Weight indexing for depthwise layout [K_h, K_w, OC] - int64_t weight_idx = kh * (config.kernel.w * config.channels.out) + - kw * config.channels.out + out_c; - sum += static_cast(quantized_weight_data[weight_idx]); - } - } - weight_sums_data[out_c] = sum; - } - - // Bias (optional, float/half) - [C_out] - ValueSpec bias( - {config.channels.out}, // Per output channel - input_dtype, - fp_storage_type, - utils::kWidthPacked, - DataGenType::RANDOM); - bias.set_constant(true); - - // Output quantization parameters - float output_scale_val = 0.05314; - ValueSpec output_scale(output_scale_val); - - int32_t output_zero_point_val = -1; - ValueSpec output_zero_point(output_zero_point_val); - - // Stride and padding parameters - ValueSpec stride({config.stride.h, config.stride.w}); - ValueSpec padding({config.padding.h, config.padding.w}); - - // Dilation and groups parameters - ValueSpec dilation({config.dilation.h, config.dilation.w}); - ValueSpec groups(config.groups); - - // Kernel size parameters - ValueSpec kernel_size({config.kernel.h, config.kernel.w}); - - // Output tensor (float/half) - [N, C_out, H_out, W_out] - ValueSpec output( - {config.batch, config.channels.out, H_out, W_out}, - input_dtype, - fp_storage_type, - fp_memory_layout, - DataGenType::ZEROS); - - // Add all specs to test case for q8ta_q8csw_q8to operation - test_case.add_input_spec(input_tensor); - test_case.add_input_spec(input_scale); - test_case.add_input_spec(input_zero_point); - test_case.add_input_spec(quantized_weight); - test_case.add_input_spec(weight_sums); - test_case.add_input_spec(weight_scales); - test_case.add_input_spec(output_scale); - test_case.add_input_spec(output_zero_point); - test_case.add_input_spec(bias); - test_case.add_input_spec(kernel_size); - test_case.add_input_spec(stride); - test_case.add_input_spec(padding); - test_case.add_input_spec(dilation); - test_case.add_input_spec(groups); - - // Activation (none = no activation) - ValueSpec activation = ValueSpec::make_string("none"); - test_case.add_input_spec(activation); - - // Add memory layout parameter for the quantized tensors - ValueSpec layout_int(static_cast(int8_memory_layout)); - test_case.add_input_spec(layout_int); - - // Add impl_selector string - ValueSpec impl_selector_spec = ValueSpec::make_string(impl_selector); - test_case.add_input_spec(impl_selector_spec); - - test_case.add_output_spec(output); - - test_case.set_abs_tolerance(output_scale_val + 1e-4f); - - // Filter out quantize/dequantize shaders from timing measurements - test_case.set_shader_filter({ - "nchw_to", - "to_nchw", - "q8ta_quantize", - "q8ta_dequantize", - }); - - return test_case; -} - -// Custom FLOP calculator for quantized depthwise conv2d operation -int64_t quantized_conv2d_dw_flop_calculator(const TestCase& test_case) { - int kernel_idx = 9; // kernel_size is at index 9 for q8ta_q8csw_q8to - - // Get input and weight dimensions - const auto& input_sizes = test_case.inputs()[0].get_tensor_sizes(); - const auto& output_sizes = test_case.outputs()[0].get_tensor_sizes(); - - const auto& kernel_sizes = test_case.inputs()[kernel_idx].get_int32_data(); - - int64_t N = input_sizes[0]; - int64_t C_out = output_sizes[1]; - int64_t K_h = kernel_sizes[0]; - int64_t K_w = kernel_sizes[1]; - int64_t H_out = output_sizes[2]; - int64_t W_out = output_sizes[3]; - - // Calculate FLOPs for quantized depthwise conv2d operation - // Each output element requires: - // - K_h * K_w multiply-accumulate operations (only one input channel per - // output channel) - // - Additional operations for quantization/dequantization - int64_t output_elements = N * C_out * H_out * W_out; - int64_t ops_per_output = K_h * K_w; - - int64_t flop = output_elements * ops_per_output; - - return flop; -} - -namespace { - -// Reference implementation for activation, weight, and output quantized -// depthwise conv2d -void conv2d_q8ta_q8csw_q8to_dw_reference_impl(TestCase& test_case) { - // Extract input specifications - int32_t idx = 0; - const ValueSpec& input_spec = test_case.inputs()[idx++]; - const ValueSpec& input_scale_spec = test_case.inputs()[idx++]; - const ValueSpec& input_zeros_spec = test_case.inputs()[idx++]; - const ValueSpec& weight_spec = test_case.inputs()[idx++]; - const ValueSpec& weight_sums_spec = test_case.inputs()[idx++]; - (void)weight_sums_spec; - const ValueSpec& weight_scales_spec = test_case.inputs()[idx++]; - const ValueSpec& output_scale_spec = test_case.inputs()[idx++]; - const ValueSpec& output_zeros_spec = test_case.inputs()[idx++]; - const ValueSpec& bias_spec = test_case.inputs()[idx++]; - const ValueSpec& kernel_size_spec = test_case.inputs()[idx++]; - const ValueSpec& stride_spec = test_case.inputs()[idx++]; - const ValueSpec& padding_spec = test_case.inputs()[idx++]; - const ValueSpec& dilation_spec = test_case.inputs()[idx++]; - const ValueSpec& groups_spec = test_case.inputs()[idx++]; - - // Extract output specification (mutable reference) - ValueSpec& output_spec = test_case.outputs()[0]; - - // Get tensor dimensions - auto input_sizes = input_spec.get_tensor_sizes(); // [N, C_in, H_in, W_in] - auto weight_sizes = - weight_spec.get_tensor_sizes(); // [K_h, align_up_4(K_w), OC] - auto output_sizes = - output_spec.get_tensor_sizes(); // [N, C_out, H_out, W_out] - - int64_t N = input_sizes[0]; - int64_t C_in = input_sizes[1]; - int64_t H_in = input_sizes[2]; - int64_t W_in = input_sizes[3]; - int64_t C_out = output_sizes[1]; - int64_t H_out = output_sizes[2]; - int64_t W_out = output_sizes[3]; - - // Get kernel dimensions from kernel_size ValueSpec - auto kernel_size_data = kernel_size_spec.get_int32_data(); - int64_t K_h = kernel_size_data[0]; - int64_t K_w = kernel_size_data[1]; - - // Get stride, padding, dilation, and groups - auto stride_data = stride_spec.get_int32_data(); - auto padding_data = padding_spec.get_int32_data(); - auto dilation_data = dilation_spec.get_int32_data(); - int64_t stride_h = stride_data[0]; - int64_t stride_w = stride_data[1]; - int64_t pad_h = padding_data[0]; - int64_t pad_w = padding_data[1]; - int64_t dilation_h = dilation_data[0]; - int64_t dilation_w = dilation_data[1]; - int64_t groups = groups_spec.get_int_value(); - - // Skip large tensors only when the reference would be expensive: each - // output element costs K_h * K_w MACs (one input channel per output - // channel), so large-dim cases with few total operations still validate. - const int64_t reference_operations = N * C_out * H_out * W_out * K_h * K_w; - const bool has_large_dimension = N > kRefDimSizeLimit || - C_in > kRefDimSizeLimit || H_in > kRefDimSizeLimit || - W_in > kRefDimSizeLimit || C_out > kRefDimSizeLimit; - if (has_large_dimension && reference_operations > kRefOperationLimit) { - throw std::invalid_argument( - "One or more dimensions exceed the allowed limit for reference implementation."); - } - - if (input_spec.dtype != vkapi::kFloat) { - throw std::invalid_argument("Unsupported dtype"); - } - - // Verify this is a depthwise convolution - if (groups != C_out || C_in != C_out) { - throw std::invalid_argument( - "This is not a depthwise convolution configuration"); - } - - // Get raw data pointers - auto& input_data = input_spec.get_float_data(); - const float input_scale = input_scale_spec.get_float_value(); - const int32_t input_zero_point = input_zeros_spec.get_int_value(); - - auto& weight_data = weight_spec.get_int8_data(); - auto& weight_scales_data = weight_scales_spec.get_float_data(); - auto& bias_data = bias_spec.get_float_data(); - - const float output_scale = output_scale_spec.get_float_value(); - const int32_t output_zero_point = output_zeros_spec.get_int_value(); - - // Calculate number of output elements - int64_t num_output_elements = N * C_out * H_out * W_out; - - auto& ref_data = output_spec.get_ref_float_data(); - ref_data.resize(num_output_elements); - - // Perform activation, weight, and output quantized depthwise conv2d operation - for (int64_t n = 0; n < N; ++n) { - for (int64_t out_c = 0; out_c < C_out; ++out_c) { - for (int64_t out_h = 0; out_h < H_out; ++out_h) { - for (int64_t out_w = 0; out_w < W_out; ++out_w) { - int32_t int_sum = 0; - int32_t weight_sum = 0; // Track weight sum on the fly - - // For depthwise convolution, each output channel corresponds to one - // input channel - int64_t in_c = out_c; - - // Convolution operation with integer accumulation - for (int64_t kh = 0; kh < K_h; ++kh) { - for (int64_t kw = 0; kw < K_w; ++kw) { - // Calculate input position with dilation - int64_t in_h = out_h * stride_h - pad_h + kh * dilation_h; - int64_t in_w = out_w * stride_w - pad_w + kw * dilation_w; - - int8_t quantized_input = 0; - int8_t quantized_weight = 0; - - // Check bounds (zero padding) - if (in_h >= 0 && in_h < H_in && in_w >= 0 && in_w < W_in) { - // Get input value and quantize to int8 - int64_t input_idx = n * (C_in * H_in * W_in) + - in_c * (H_in * W_in) + in_h * W_in + in_w; - - float quant_input_f = - std::round(input_data[input_idx] / input_scale) + - input_zero_point; - quant_input_f = - std::min(std::max(quant_input_f, -128.0f), 127.0f); - quantized_input = static_cast(quant_input_f); - - // Get quantized weight using depthwise layout [K_h, K_w, OC] - int64_t weight_idx = kh * (K_w * C_out) + kw * C_out + out_c; - quantized_weight = weight_data[weight_idx]; - - // Integer multiplication and accumulation - int_sum += static_cast(quantized_input) * - static_cast(quantized_weight); - - // Track weight sum for this output channel on the fly - weight_sum += static_cast(quantized_weight); - } else { - // For zero padding, we still need to account for the weight - // in weight_sum when input is effectively 0 (but quantized 0 - // is input_zero_point) - int64_t weight_idx = kh * (K_w * C_out) + kw * C_out + out_c; - quantized_weight = weight_data[weight_idx]; - - // Use input_zero_point as the quantized input for padding - quantized_input = static_cast(input_zero_point); - - // Add contribution from zero-padded input (quantized zero = - // input_zero_point) - int_sum += static_cast(input_zero_point) * - static_cast(quantized_weight); - - // Track weight sum for this output channel on the fly - weight_sum += static_cast(quantized_weight); - } - } - } - - // Convert accumulated integer result to float and apply scales - // Final result = (int_sum - zero_point_correction) * input_scale * - // weight_scale + bias zero_point_correction = input_zero_point * - // sum_of_weights_for_this_output_channel - int32_t zero_point_correction = input_zero_point * weight_sum; - int32_t accum_adjusted = int_sum - zero_point_correction; - float float_result = - accum_adjusted * input_scale * weight_scales_data[out_c]; - - // Add bias and store result - float_result += bias_data[out_c]; - - // Quantize the output to int8 - float quant_output_f = - std::round(float_result / output_scale) + output_zero_point; - quant_output_f = std::min(std::max(quant_output_f, -128.0f), 127.0f); - int8_t quantized_output = static_cast(quant_output_f); - - // Dequantize back to float - float dequant_output = - (static_cast(quantized_output) - output_zero_point) * - output_scale; - - int64_t output_idx = n * (C_out * H_out * W_out) + - out_c * (H_out * W_out) + out_h * W_out + out_w; - ref_data[output_idx] = dequant_output; - } - } - } - } -} - -} // namespace - -void dw_reference_impl(TestCase& test_case) { - conv2d_q8ta_q8csw_q8to_dw_reference_impl(test_case); -} - -} // namespace q8ta_conv2d -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/conv2d_pw.cpp b/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/conv2d_pw.cpp deleted file mode 100644 index 244825c7525..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/conv2d_pw.cpp +++ /dev/null @@ -1,433 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include -#include -#include -#include -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8ta_conv2d { - -// Utility function to create a test case from a Conv2dConfig -TestCase create_pw_test_case_from_config( - const Conv2dConfig& config, - vkapi::ScalarType input_dtype, - utils::StorageType fp_storage_type, - utils::GPUMemoryLayout int8_memory_layout, - const std::string& impl_selector, - const PointwiseTestOptions& options) { - TestCase test_case; - - // Calculate output dimensions - int64_t H_out = config.get_output_height(); - int64_t W_out = config.get_output_width(); - - // Input tensor (float/half) - [N, C_in, H_in, W_in] - std::vector input_size = { - config.batch, - config.channels.in, - config.input_size.h, - config.input_size.w}; - - utils::GPUMemoryLayout fp_memory_layout = fp_storage_type == utils::kBuffer - ? utils::kWidthPacked - : utils::kChannelsPacked; - - // Create test case name - std::string prefix = config.test_case_name.substr(0, 4); // "ACCU" or "PERF" - std::string dtype_str = dtype_short(input_dtype); - std::string in_shape = "[" + std::to_string(config.batch) + "," + - std::to_string(config.channels.in) + "," + - std::to_string(config.input_size.h) + "," + - std::to_string(config.input_size.w) + "]"; - std::string weight_shape = "[" + std::to_string(config.channels.out) + "," + - std::to_string(config.channels.in / config.groups) + "," + - std::to_string(config.kernel.h) + "," + std::to_string(config.kernel.w) + - "]"; - std::string shape_str = in_shape + "x" + weight_shape + " s" + - std::to_string(config.stride.h) + " p" + - std::to_string(config.padding.h) + " d" + - std::to_string(config.dilation.h) + " g" + std::to_string(config.groups); - std::string storage_str = repr_str(utils::kBuffer, int8_memory_layout); - std::string suffix = impl_selector.empty() ? "" : "[" + impl_selector + "]"; - std::string test_name = make_test_label( - prefix, dtype_str, dtype_str, shape_str, storage_str, suffix); - test_case.set_name(test_name); - - // Set the operator name for the test case - use the dedicated pointwise test - // operator - std::string operator_name = "test_etvk.test_q8ta_conv2d_pw.default"; - test_case.set_operator_name(operator_name); - - ValueSpec input_tensor( - input_size, - input_dtype, - fp_storage_type, - fp_memory_layout, - DataGenType::RANDOM); - - if (debugging()) { - print_valuespec_data(input_tensor, "input_tensor"); - } - - float input_scale_val = 0.008123; - ValueSpec input_scale(input_scale_val); - - const int32_t input_zero_point_val = options.input_zero_point; - ValueSpec input_zero_point(input_zero_point_val); - - // Quantized weight tensor (int8) - [C_out, C_in_per_group * K_h * K_w] - // Memory layout: height, width, then channels - in_c is innermost (stride 1) - // in the second dimension - const int64_t in_channels_per_group = config.channels.in / config.groups; - const int64_t in_features = utils::align_up_4( - in_channels_per_group * config.kernel.h * config.kernel.w); - std::vector weight_size = {config.channels.out, in_features}; - ValueSpec quantized_weight( - weight_size, - vkapi::kChar, // int8 for quantized weights - fp_storage_type, - utils::kWidthPacked, - DataGenType::RANDINT8); - quantized_weight.set_constant(true); - std::vector& weight_data = quantized_weight.get_int8_data(); - for (int64_t out_channel = 0; out_channel < config.channels.out; - ++out_channel) { - const auto padding_begin = - weight_data.begin() + out_channel * in_features + in_channels_per_group; - const auto padding_end = - weight_data.begin() + (out_channel + 1) * in_features; - std::fill(padding_begin, padding_end, 0); - } - - if (debugging()) { - print_valuespec_data(quantized_weight, "weight_tensor"); - } - - const int64_t aligned_out_channels = utils::align_up_4(config.channels.out); - - // Weight quantization scales (float/half, per-channel) - ValueSpec weight_scales( - {aligned_out_channels}, // Per output channel - input_dtype, - fp_storage_type, - utils::kWidthPacked, - DataGenType::RANDOM_SCALES); - weight_scales.set_constant(true); - - ValueSpec weight_sums( - {aligned_out_channels}, // Per output channel - vkapi::kInt, - fp_storage_type, - utils::kWidthPacked, - DataGenType::ZEROS); - weight_sums.set_constant(true); - - // Compute weight_sums data based on quantized weights - compute_weight_sums( - weight_sums, quantized_weight, config.channels.out, in_features); - - // Bias (optional, float/half) - [C_out] - ValueSpec bias( - {aligned_out_channels}, // Per output channel - input_dtype, - fp_storage_type, - utils::kWidthPacked, - DataGenType::ZEROS); - bias.set_constant(true); - bias.set_none(!options.has_bias); - - // Output quantization parameters - float output_scale_val = 0.05314; - ValueSpec output_scale(output_scale_val); - - int32_t output_zero_point_val = -1; - ValueSpec output_zero_point(output_zero_point_val); - - // Stride and padding parameters - ValueSpec stride({config.stride.h, config.stride.w}); - ValueSpec padding({config.padding.h, config.padding.w}); - - // Dilation and groups parameters - ValueSpec dilation({config.dilation.h, config.dilation.w}); - ValueSpec groups(config.groups); - - // Kernel size parameters - ValueSpec kernel_size({config.kernel.h, config.kernel.w}); - - // Output tensor (float/half) - [N, C_out, H_out, W_out] - ValueSpec output( - {config.batch, config.channels.out, H_out, W_out}, - input_dtype, - fp_storage_type, - fp_memory_layout, - DataGenType::ZEROS); - - // Add all specs to test case for q8ta_conv2d_pw operation - test_case.add_input_spec(input_tensor); - test_case.add_input_spec(input_scale); - test_case.add_input_spec(input_zero_point); - test_case.add_input_spec(quantized_weight); - test_case.add_input_spec(weight_sums); - test_case.add_input_spec(weight_scales); - test_case.add_input_spec(output_scale); - test_case.add_input_spec(output_zero_point); - test_case.add_input_spec(bias); - test_case.add_input_spec(kernel_size); - test_case.add_input_spec(stride); - test_case.add_input_spec(padding); - test_case.add_input_spec(dilation); - test_case.add_input_spec(groups); - - // Activation (none = no activation) - ValueSpec activation = ValueSpec::make_string("none"); - test_case.add_input_spec(activation); - - // Add memory layout parameter for the quantized tensors - ValueSpec layout_int(static_cast(int8_memory_layout)); - test_case.add_input_spec(layout_int); - - // Add impl_selector string - ValueSpec impl_selector_spec = ValueSpec::make_string(impl_selector); - test_case.add_input_spec(impl_selector_spec); - - test_case.add_output_spec(output); - - test_case.set_abs_tolerance(output_scale_val + 1e-4f); - - // Filter out quantize/dequantize shaders from timing measurements - test_case.set_shader_filter({ - "nchw_to", - "to_nchw", - "q8ta_quantize", - "q8ta_dequantize", - }); - - return test_case; -} - -namespace { - -// Reference implementation for activation, weight, and output quantized conv2d -void conv2d_q8ta_q8csw_q8to_pw_reference_impl(TestCase& test_case) { - // Extract input specifications - int32_t idx = 0; - const ValueSpec& input_spec = test_case.inputs()[idx++]; - const ValueSpec& input_scale_spec = test_case.inputs()[idx++]; - const ValueSpec& input_zeros_spec = test_case.inputs()[idx++]; - const ValueSpec& weight_spec = test_case.inputs()[idx++]; - const ValueSpec& weight_sums_spec = test_case.inputs()[idx++]; - (void)weight_sums_spec; - const ValueSpec& weight_scales_spec = test_case.inputs()[idx++]; - const ValueSpec& output_scale_spec = test_case.inputs()[idx++]; - const ValueSpec& output_zeros_spec = test_case.inputs()[idx++]; - const ValueSpec& bias_spec = test_case.inputs()[idx++]; - const ValueSpec& kernel_size_spec = test_case.inputs()[idx++]; - const ValueSpec& stride_spec = test_case.inputs()[idx++]; - const ValueSpec& padding_spec = test_case.inputs()[idx++]; - const ValueSpec& dilation_spec = test_case.inputs()[idx++]; - const ValueSpec& groups_spec = test_case.inputs()[idx++]; - const ValueSpec& activation_spec = test_case.inputs()[idx++]; - (void)activation_spec; // Not used in reference implementation - const ValueSpec& layout_spec = test_case.inputs()[idx++]; - (void)layout_spec; // Not used in reference implementation - const ValueSpec& impl_selector_spec = test_case.inputs()[idx++]; - (void)impl_selector_spec; // Not used in reference implementation - - // Extract output specification (mutable reference) - ValueSpec& output_spec = test_case.outputs()[0]; - - // Get tensor dimensions - auto input_sizes = input_spec.get_tensor_sizes(); // [N, C_in, H_in, W_in] - auto weight_sizes = - weight_spec.get_tensor_sizes(); // [C_out, C_in_per_group * K_h * K_w] - auto output_sizes = - output_spec.get_tensor_sizes(); // [N, C_out, H_out, W_out] - - int64_t N = input_sizes[0]; - int64_t C_in = input_sizes[1]; - int64_t H_in = input_sizes[2]; - int64_t W_in = input_sizes[3]; - int64_t C_out = output_sizes[1]; - int64_t H_out = output_sizes[2]; - int64_t W_out = output_sizes[3]; - - // Get kernel dimensions from kernel_size ValueSpec - auto kernel_size_data = kernel_size_spec.get_int32_data(); - int64_t K_h = kernel_size_data[0]; - int64_t K_w = kernel_size_data[1]; - - // Get stride, padding, dilation, and groups - auto stride_data = stride_spec.get_int32_data(); - auto padding_data = padding_spec.get_int32_data(); - auto dilation_data = dilation_spec.get_int32_data(); - int64_t stride_h = stride_data[0]; - int64_t stride_w = stride_data[1]; - int64_t pad_h = padding_data[0]; - int64_t pad_w = padding_data[1]; - int64_t dilation_h = dilation_data[0]; - int64_t dilation_w = dilation_data[1]; - int64_t groups = groups_spec.get_int_value(); - - // Skip for large tensors since computation time will be extremely slow - if (N > kRefDimSizeLimit || C_in > kRefDimSizeLimit || - H_in > kRefDimSizeLimit || W_in > kRefDimSizeLimit || - C_out > kRefDimSizeLimit) { - throw std::invalid_argument( - "One or more dimensions exceed the allowed limit for reference implementation."); - std::cout - << "Reference implementation: computation may take some time for large tensors..." - << std::endl; - } - - if (input_spec.dtype != vkapi::kFloat) { - throw std::invalid_argument("Unsupported dtype"); - } - - // Get raw data pointers - auto& input_data = input_spec.get_float_data(); - const float input_scale = input_scale_spec.get_float_value(); - const int32_t input_zero_point = input_zeros_spec.get_int_value(); - - auto& weight_data = weight_spec.get_int8_data(); - auto& weight_scales_data = weight_scales_spec.get_float_data(); - auto& bias_data = bias_spec.get_float_data(); - const bool has_bias = !bias_spec.is_none(); - - const float output_scale = output_scale_spec.get_float_value(); - const int32_t output_zero_point = output_zeros_spec.get_int_value(); - - // Calculate channels per group for grouped convolution - int64_t C_in_per_group = C_in / groups; - int64_t C_out_per_group = C_out / groups; - - // Calculate number of output elements - int64_t num_output_elements = N * C_out * H_out * W_out; - - auto& ref_data = output_spec.get_ref_float_data(); - ref_data.resize(num_output_elements); - - const int64_t in_features = utils::align_up_4(C_in_per_group * K_h * K_w); - - // Perform activation, weight, and output quantized conv2d operation - for (int64_t n = 0; n < N; ++n) { - for (int64_t out_c = 0; out_c < C_out; ++out_c) { - for (int64_t out_h = 0; out_h < H_out; ++out_h) { - for (int64_t out_w = 0; out_w < W_out; ++out_w) { - int32_t int_sum = 0; - int32_t weight_sum = 0; // Track weight sum on the fly - - // Determine which group this output channel belongs to - int64_t group_idx = out_c / C_out_per_group; - int64_t in_c_start = group_idx * C_in_per_group; - int64_t in_c_end = (group_idx + 1) * C_in_per_group; - - // Convolution operation with integer accumulation - for (int64_t in_c = in_c_start; in_c < in_c_end; ++in_c) { - for (int64_t kh = 0; kh < K_h; ++kh) { - for (int64_t kw = 0; kw < K_w; ++kw) { - // Calculate input position with dilation - int64_t in_h = out_h * stride_h - pad_h + kh * dilation_h; - int64_t in_w = out_w * stride_w - pad_w + kw * dilation_w; - - // Check bounds (zero padding) - if (in_h >= 0 && in_h < H_in && in_w >= 0 && in_w < W_in) { - // Get input value and quantize to int8 - int64_t input_idx = n * (C_in * H_in * W_in) + - in_c * (H_in * W_in) + in_h * W_in + in_w; - - float quant_input_f = - std::round(input_data[input_idx] / input_scale) + - input_zero_point; - quant_input_f = - std::min(std::max(quant_input_f, -128.0f), 127.0f); - int8_t quantized_input = static_cast(quant_input_f); - - // Get quantized weight (already int8) - // Weight layout: [C_out, C_in_per_group * K_h * K_w] - int64_t weight_idx = out_c * in_features + - (kh * (K_w * C_in_per_group) + kw * C_in_per_group + - (in_c % C_in_per_group)); - int8_t quantized_weight = weight_data[weight_idx]; - - // Integer multiplication and accumulation - int_sum += static_cast(quantized_input) * - static_cast(quantized_weight); - - // Track weight sum for this output channel on the fly - weight_sum += static_cast(quantized_weight); - } else { - // For zero padding, we still need to account for the weight - // in weight_sum when input is effectively 0 (but quantized 0 - // is input_zero_point) - int64_t weight_idx = out_c * in_features + - (kh * (K_w * C_in_per_group) + kw * C_in_per_group + - (in_c % C_in_per_group)); - int8_t quantized_weight = weight_data[weight_idx]; - - // Add contribution from zero-padded input (quantized zero = - // input_zero_point) - int_sum += static_cast(input_zero_point) * - static_cast(quantized_weight); - - // Track weight sum for this output channel on the fly - weight_sum += static_cast(quantized_weight); - } - } - } - } - - // Convert accumulated integer result to float and apply scales - // Final result = (int_sum - zero_point_correction) * input_scale * - // weight_scale + bias zero_point_correction = input_zero_point * - // sum_of_weights_for_this_output_channel - int32_t zero_point_correction = input_zero_point * weight_sum; - int32_t accum_adjusted = int_sum - zero_point_correction; - float float_result = - accum_adjusted * input_scale * weight_scales_data[out_c]; - - // Add bias and store result - if (has_bias) { - float_result += bias_data[out_c]; - } - - // Quantize the output to int8 - float quant_output_f = - std::round(float_result / output_scale) + output_zero_point; - quant_output_f = std::min(std::max(quant_output_f, -128.0f), 127.0f); - int8_t quantized_output = static_cast(quant_output_f); - - // Dequantize back to float - float dequant_output = - (static_cast(quantized_output) - output_zero_point) * - output_scale; - - int64_t output_idx = n * (C_out * H_out * W_out) + - out_c * (H_out * W_out) + out_h * W_out + out_w; - ref_data[output_idx] = dequant_output; - } - } - } - } -} - -} // namespace - -void pw_reference_impl(TestCase& test_case) { - conv2d_q8ta_q8csw_q8to_pw_reference_impl(test_case); -} - -} // namespace q8ta_conv2d -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/conv2d_sets.cpp b/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/conv2d_sets.cpp deleted file mode 100644 index cdabdc58969..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/conv2d_sets.cpp +++ /dev/null @@ -1,319 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include -#include - -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8ta_conv2d { - -namespace { - -std::vector generate_config_test_cases( - const std::vector& configs, - const std::vector& batch_configs, - const std::vector& fp_storage_types, - const std::vector& int8_memory_layouts) { - std::vector test_cases; - if (!vkcompute::api::context()->adapter_ptr()->supports_int8_dot_product()) { - return test_cases; - } - - // Generate test cases for each combination - for (auto config : configs) { - bool is_performance = config.channels.out > kRefDimSizeLimit || - config.channels.in > kRefDimSizeLimit || - config.input_size.h > kRefDimSizeLimit || - config.input_size.w > kRefDimSizeLimit; - - config.op_name = "conv2d_q8ta_q8csw_q8to"; - - for (const utils::StorageType fp_storage_type : fp_storage_types) { - for (const utils::GPUMemoryLayout int8_memory_layout : - int8_memory_layouts) { - config.test_case_name = make_test_case_name( - config, is_performance, fp_storage_type, utils::kBuffer); - - test_cases.push_back(create_test_case_from_config( - config, - vkapi::kFloat, - fp_storage_type, - int8_memory_layout, - /*impl_selector=*/"general")); - - // Test im2col implementation when input channels per group is a - // multiple of 4 - const int64_t in_channels_per_group = - config.channels.in / config.groups; - if (in_channels_per_group % 4 == 0) { - test_cases.push_back(create_test_case_from_config( - config, - vkapi::kFloat, - fp_storage_type, - int8_memory_layout, - /*impl_selector=*/"im2col")); - } - - // For 4W4C layout, also test the legacy implementation - if (int8_memory_layout == utils::kPackedInt8_4W4C) { - test_cases.push_back(create_test_case_from_config( - config, - vkapi::kFloat, - fp_storage_type, - int8_memory_layout, - /*impl_selector=*/"legacy_4w4c")); - } - - test_cases.push_back(create_test_case_from_config( - config, vkapi::kFloat, fp_storage_type, int8_memory_layout)); - } - } - } - - for (auto config : batch_configs) { - const bool is_performance = config.batch > kRefDimSizeLimit || - config.channels.out > kRefDimSizeLimit || - config.channels.in > kRefDimSizeLimit || - config.input_size.h > kRefDimSizeLimit || - config.input_size.w > kRefDimSizeLimit; - config.op_name = "conv2d_q8ta_q8csw_q8to"; - config.test_case_name = make_test_case_name( - config, is_performance, utils::kTexture3D, utils::kBuffer); - test_cases.push_back(create_test_case_from_config( - config, vkapi::kFloat, utils::kTexture3D, utils::kPackedInt8_4C1W)); - if (config.batch == 2) { - test_cases.push_back(create_test_case_from_config( - config, - vkapi::kFloat, - utils::kTexture3D, - utils::kPackedInt8_4C1W, - /*impl_selector=*/"im2col")); - test_cases.push_back(create_test_case_from_config( - config, vkapi::kFloat, utils::kTexture3D, utils::kPackedInt8_4W4C)); - test_cases.push_back(create_test_case_from_config( - config, - vkapi::kFloat, - utils::kTexture3D, - utils::kPackedInt8_4W4C, - /*impl_selector=*/"im2col")); - } - } - - return test_cases; -} - -} // namespace - -REGISTER_TEST_CASE_SET("q8ta/conv2d", "correctness") { - const std::vector configs = { - // General 2D convolutions - {OutInChannels(32, 3), - InputSize2D(64, 64), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 1}, - {OutInChannels(32, 3), - InputSize2D(64, 64), - KernelSize(3, 3), - Stride(2, 2), - Padding(1, 1), - Dilation(1, 1), - 1}, - {OutInChannels(64, 32), - InputSize2D(8, 8), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 1}, - {OutInChannels(64, 32), - InputSize2D(64, 64), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 1}, - {OutInChannels(64, 32), - InputSize2D(64, 64), - KernelSize(3, 3), - Stride(2, 2), - Padding(1, 1), - Dilation(1, 1), - 1}, - {OutInChannels(16, 32), - InputSize2D(77, 77), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 1}, - // Grouped convolutions - {OutInChannels(64, 32), - InputSize2D(64, 64), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 2}, - {OutInChannels(96, 96), - InputSize2D(81, 81), - KernelSize(3, 3), - Stride(2, 2), - Padding(1, 1), - Dilation(1, 1), - 3}, - {OutInChannels(96, 96), - InputSize2D(64, 64), - KernelSize(5, 5), - Stride(2, 2), - Padding(2, 2), - Dilation(1, 1), - 4}, - }; - const std::vector batch_configs = { - {OutInChannels(16, 32), - InputSize2D(7, 7), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 1, - 2}, - }; - const std::vector fp_storage_types = {utils::kTexture3D}; - const std::vector int8_memory_layouts = { - utils::kPackedInt8_4C1W, utils::kPackedInt8_4W4C, utils::kPackedInt8_4C}; - return { - generate_config_test_cases( - configs, batch_configs, fp_storage_types, int8_memory_layouts), - reference_impl, - quantized_conv2d_flop_calculator}; -} - -REGISTER_TEST_CASE_SET("q8ta/conv2d", "performance") { - const std::vector configs = { - // Performance cases (3x3 convs - will use im2col) - {OutInChannels(32, 3), - InputSize2D(256, 256), - KernelSize(3, 3), - Stride(1, 1), - Padding(0, 0), - Dilation(1, 1), - 1}, - {OutInChannels(64, 32), - InputSize2D(128, 128), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 1}, - {OutInChannels(64, 64), - InputSize2D(128, 128), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 1}, - // Performance cases (grouped convs) - {OutInChannels(64, 64), - InputSize2D(128, 128), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 2}, - {OutInChannels(96, 96), - InputSize2D(128, 128), - KernelSize(3, 3), - Stride(2, 2), - Padding(1, 1), - Dilation(1, 1), - 3}, - {OutInChannels(128, 128), - InputSize2D(128, 128), - KernelSize(5, 5), - Stride(2, 2), - Padding(2, 2), - Dilation(1, 1), - 4}, - // SceneX v9 grouped convolutions (large spatial) - {OutInChannels(128, 128), - InputSize2D(256, 256), - KernelSize(5, 5), - Stride(2, 2), - Padding(2, 2), - Dilation(1, 1), - 4}, - {OutInChannels(64, 64), - InputSize2D(256, 256), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 2}, - // Deep channels + small spatial (ResNet50 stage 5 bottleneck) - {OutInChannels(512, 512), - InputSize2D(7, 7), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 1}, - // Strided 1x1 shortcut (worst-case strided downsample) - {OutInChannels(2048, 1024), - InputSize2D(14, 14), - KernelSize(1, 1), - Stride(2, 2), - Padding(0, 0), - Dilation(1, 1), - 1}, - }; - const std::vector batch_configs = { - {OutInChannels(32, 3), - InputSize2D(256, 256), - KernelSize(3, 3), - Stride(2, 2), - Padding(1, 1), - Dilation(1, 1), - 1, - 1}, - {OutInChannels(32, 3), - InputSize2D(256, 256), - KernelSize(3, 3), - Stride(2, 2), - Padding(1, 1), - Dilation(1, 1), - 1, - 60}, - {OutInChannels(512, 256), - InputSize2D(10, 13), - KernelSize(3, 3), - Stride(2, 2), - Padding(1, 1), - Dilation(1, 1), - 1, - 60}, - }; - const std::vector fp_storage_types = {utils::kTexture3D}; - const std::vector int8_memory_layouts = { - utils::kPackedInt8_4C1W, utils::kPackedInt8_4W4C, utils::kPackedInt8_4C}; - return { - generate_config_test_cases( - configs, batch_configs, fp_storage_types, int8_memory_layouts), - reference_impl, - quantized_conv2d_flop_calculator}; -} - -} // namespace q8ta_conv2d -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/debug_set.cpp b/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/debug_set.cpp deleted file mode 100644 index 255f92add31..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/debug_set.cpp +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include -#include - -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8ta_conv2d { - -namespace { - -// Generate easy test cases for quantized conv2d operation (for debugging) -std::vector generate_quantized_conv2d_easy_cases( - const Conv2dConfig& source_config, - const std::vector& fp_storage_types, - const std::vector& int8_memory_layouts) { - std::vector test_cases; - Conv2dConfig config = source_config; - config.op_name = "conv2d_q8ta_q8csw_q8to"; - - // Generate test cases for each combination - for (const utils::StorageType fp_storage_type : fp_storage_types) { - for (const utils::GPUMemoryLayout int8_memory_layout : - int8_memory_layouts) { - config.test_case_name = - make_test_case_name(config, false, fp_storage_type, utils::kBuffer); - test_cases.push_back(create_test_case_from_config( - config, vkapi::kFloat, fp_storage_type, int8_memory_layout)); - - // Test im2col implementation when input channels per group is a - // multiple of 4 - if ((config.channels.in / config.groups) % 4 == 0) { - test_cases.push_back(create_test_case_from_config( - config, - vkapi::kFloat, - fp_storage_type, - int8_memory_layout, - /*impl_selector=*/"im2col")); - } - // For 4W4C layout, also test the legacy implementation - if (int8_memory_layout == utils::kPackedInt8_4W4C) { - test_cases.push_back(create_test_case_from_config( - config, - vkapi::kFloat, - fp_storage_type, - int8_memory_layout, - /*impl_selector=*/"legacy_4w4c")); - } - } - } - - return test_cases; -} - -} // namespace - -REGISTER_TEST_CASE_SET("q8ta/conv2d", "debug") { - // Single simple configuration for debugging - const Conv2dConfig config = { - OutInChannels(16, 8), // channels (out, in) - InputSize2D(5, 5), // input_size (h, w) - KernelSize(3, 3), // kernel - Stride(1, 1), // stride - Padding(1, 1), // padding - Dilation(1, 1), // dilation - 1, // groups - }; - const std::vector fp_storage_types = {utils::kTexture3D}; - const std::vector int8_memory_layouts = { - utils::kPackedInt8_4C1W, utils::kPackedInt8_4W4C, utils::kPackedInt8_4C}; - return { - generate_quantized_conv2d_easy_cases( - config, fp_storage_types, int8_memory_layouts), - reference_impl, - quantized_conv2d_flop_calculator}; -} - -} // namespace q8ta_conv2d -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/dw_debug_set.cpp b/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/dw_debug_set.cpp deleted file mode 100644 index b5395dda31c..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/dw_debug_set.cpp +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include -#include - -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8ta_conv2d { - -namespace { - -// Generate easy test cases for quantized depthwise conv2d operation (for -// debugging) -std::vector generate_quantized_conv2d_dw_easy_cases( - const Conv2dConfig& source_config, - const std::vector& fp_storage_types, - const std::vector& int8_memory_layouts) { - std::vector test_cases; - Conv2dConfig config = source_config; - config.op_name = "conv2d_q8ta_q8csw_q8to"; - - // Generate test cases for each combination - for (const utils::StorageType fp_storage_type : fp_storage_types) { - for (const utils::GPUMemoryLayout int8_memory_layout : - int8_memory_layouts) { - config.test_case_name = - make_test_case_name(config, false, fp_storage_type, utils::kBuffer); - test_cases.push_back(create_dw_test_case_from_config( - config, vkapi::kFloat, fp_storage_type, int8_memory_layout)); - - // For 4W4C layout, also test the legacy implementation - if (int8_memory_layout == utils::kPackedInt8_4W4C) { - test_cases.push_back(create_dw_test_case_from_config( - config, - vkapi::kFloat, - fp_storage_type, - int8_memory_layout, - /*impl_selector=*/"legacy_4w4c")); - } - } - } - - return test_cases; -} - -} // namespace - -REGISTER_TEST_CASE_SET("q8ta/conv2d", "dw_debug") { - // Single simple configuration for debugging - depthwise convolution - const Conv2dConfig config = { - OutInChannels(8, 8), // channels (out, in) - equal for depthwise - InputSize2D(8, 8), // input_size (h, w) - KernelSize(3, 3), // kernel - Stride(1, 1), // stride - Padding(1, 1), // padding - Dilation(1, 1), // dilation - 8, // groups = channels.out for depthwise - }; - const std::vector fp_storage_types = {utils::kTexture3D}; - const std::vector int8_memory_layouts = { - utils::kPackedInt8_4C1W, utils::kPackedInt8_4W4C, utils::kPackedInt8_4C}; - return { - generate_quantized_conv2d_dw_easy_cases( - config, fp_storage_types, int8_memory_layouts), - dw_reference_impl, - quantized_conv2d_dw_flop_calculator}; -} - -} // namespace q8ta_conv2d -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/dw_narrow_workgroup_set.cpp b/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/dw_narrow_workgroup_set.cpp deleted file mode 100644 index 49ba8573915..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/dw_narrow_workgroup_set.cpp +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include -#include - -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8ta_conv2d { - -namespace { - -std::vector generate_quantized_conv2d_dw_narrow_workgroup_cases( - const std::vector& configs) { - std::vector test_cases; - for (Conv2dConfig config : configs) { - const bool is_performance = config.channels.out > kRefDimSizeLimit; - config.op_name = "conv2d_q8ta_q8csw_q8to"; - config.test_case_name = make_test_case_name( - config, is_performance, utils::kTexture3D, utils::kBuffer); - test_cases.push_back(create_dw_test_case_from_config( - config, vkapi::kFloat, utils::kTexture3D, utils::kPackedInt8_4C)); - } - return test_cases; -} - -} // namespace - -REGISTER_TEST_CASE_SET("q8ta/conv2d", "dw_narrow_workgroup") { - const std::vector configs = { - {OutInChannels(128, 128), - InputSize2D(7, 7), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 128}, - {OutInChannels(64, 64), - InputSize2D(9, 9), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 64}, - {OutInChannels(64, 64), - InputSize2D(13, 13), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 64}, - }; - return { - generate_quantized_conv2d_dw_narrow_workgroup_cases(configs), - dw_reference_impl, - quantized_conv2d_dw_flop_calculator}; -} - -} // namespace q8ta_conv2d -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/dw_sets.cpp b/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/dw_sets.cpp deleted file mode 100644 index 37a9d4e0cf6..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/dw_sets.cpp +++ /dev/null @@ -1,216 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include -#include - -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8ta_conv2d { - -namespace { - -std::vector generate_dw_test_cases( - const std::vector& configs, - const std::vector& batch_configs, - const std::vector& fp_storage_types, - const std::vector& int8_memory_layouts) { - std::vector test_cases; - if (!vkcompute::api::context()->adapter_ptr()->supports_int8_dot_product()) { - return test_cases; - } - - // Generate test cases for each combination - for (auto config : configs) { - bool is_performance = config.channels.out > kRefDimSizeLimit || - config.channels.in > kRefDimSizeLimit || - config.input_size.h > kRefDimSizeLimit || - config.input_size.w > kRefDimSizeLimit; - - config.op_name = "conv2d_q8ta_q8csw_q8to"; - - for (const utils::StorageType fp_storage_type : fp_storage_types) { - for (const utils::GPUMemoryLayout int8_memory_layout : - int8_memory_layouts) { - config.test_case_name = make_test_case_name( - config, is_performance, fp_storage_type, utils::kBuffer); - test_cases.push_back(create_dw_test_case_from_config( - config, vkapi::kFloat, fp_storage_type, int8_memory_layout)); - - // For 4W4C layout, also test the legacy implementation - if (int8_memory_layout == utils::kPackedInt8_4W4C) { - test_cases.push_back(create_dw_test_case_from_config( - config, - vkapi::kFloat, - fp_storage_type, - int8_memory_layout, - /*impl_selector=*/"legacy_4w4c")); - } - } - } - } - - for (auto config : batch_configs) { - const bool is_performance = config.channels.out > kRefDimSizeLimit || - config.channels.in > kRefDimSizeLimit; - config.op_name = "conv2d_q8ta_q8csw_q8to"; - config.test_case_name = make_test_case_name( - config, is_performance, utils::kTexture3D, utils::kBuffer); - test_cases.push_back(create_dw_test_case_from_config( - config, vkapi::kFloat, utils::kTexture3D, utils::kPackedInt8_4C1W)); - if (config.batch == 2) { - test_cases.push_back(create_dw_test_case_from_config( - config, vkapi::kFloat, utils::kTexture3D, utils::kPackedInt8_4W4C)); - } - } - - return test_cases; -} - -} // namespace - -REGISTER_TEST_CASE_SET("q8ta/conv2d", "dw_correctness") { - const std::vector configs = { - // Depthwise convolutions: groups = channels.out, channels.in = - // channels.out - {OutInChannels(32, 32), - InputSize2D(64, 64), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 32}, - {OutInChannels(64, 64), - InputSize2D(32, 32), - KernelSize(3, 3), - Stride(2, 2), - Padding(2, 2), - Dilation(1, 1), - 64}, - {OutInChannels(64, 64), - InputSize2D(32, 32), - KernelSize(3, 3), - Stride(2, 2), - Padding(1, 1), - Dilation(1, 1), - 64}, - {OutInChannels(80, 80), - InputSize2D(16, 16), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 80}, - {OutInChannels(16, 16), - InputSize2D(57, 33), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 16}, - // Different kernel sizes for depthwise - {OutInChannels(32, 32), - InputSize2D(64, 64), - KernelSize(5, 5), - Stride(1, 1), - Padding(2, 2), - Dilation(1, 1), - 32}, - {OutInChannels(96, 96), - InputSize2D(64, 64), - KernelSize(5, 5), - Stride(2, 2), - Padding(2, 2), - Dilation(1, 1), - 96}, - }; - const std::vector batch_configs = { - {OutInChannels(8, 8), - InputSize2D(8, 8), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 8, - 2}, - }; - const std::vector fp_storage_types = {utils::kTexture3D}; - const std::vector int8_memory_layouts = { - utils::kPackedInt8_4C1W, utils::kPackedInt8_4W4C, utils::kPackedInt8_4C}; - return { - generate_dw_test_cases( - configs, batch_configs, fp_storage_types, int8_memory_layouts), - dw_reference_impl, - quantized_conv2d_dw_flop_calculator}; -} - -REGISTER_TEST_CASE_SET("q8ta/conv2d", "dw_performance") { - const std::vector configs = { - // Performance cases - {OutInChannels(128, 128), - InputSize2D(128, 128), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 128}, - {OutInChannels(64, 64), - InputSize2D(256, 256), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 64}, - {OutInChannels(288, 288), - InputSize2D(16, 16), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 288}, - {OutInChannels(32, 32), - InputSize2D(128, 128), - KernelSize(3, 3), - Stride(1, 1), - Padding(2, 2), - Dilation(1, 1), - 32}, - }; - const std::vector batch_configs = { - {OutInChannels(128, 128), - InputSize2D(64, 64), - KernelSize(5, 5), - Stride(2, 2), - Padding(2, 2), - Dilation(1, 1), - 128, - 1}, - {OutInChannels(128, 128), - InputSize2D(64, 64), - KernelSize(5, 5), - Stride(2, 2), - Padding(2, 2), - Dilation(1, 1), - 128, - 60}, - }; - const std::vector fp_storage_types = {utils::kTexture3D}; - const std::vector int8_memory_layouts = { - utils::kPackedInt8_4C1W, utils::kPackedInt8_4W4C, utils::kPackedInt8_4C}; - return { - generate_dw_test_cases( - configs, batch_configs, fp_storage_types, int8_memory_layouts), - dw_reference_impl, - quantized_conv2d_dw_flop_calculator}; -} - -} // namespace q8ta_conv2d -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/im2col_set.cpp b/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/im2col_set.cpp deleted file mode 100644 index df7ccce3f52..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/im2col_set.cpp +++ /dev/null @@ -1,206 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include -#include - -#include -#include - -#include -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8ta_conv2d { - -namespace { - -TestCase create_im2col_test_case( - const Conv2dConfig& source_config, - utils::StorageType fp_storage_type, - const std::string& impl_selector, - const Im2colUnsignedTestOptions& options) { - Conv2dConfig config = source_config; - config.op_name = "conv2d_q8ta_q8csw_q8to"; - config.test_case_name = - make_test_case_name(config, false, fp_storage_type, utils::kBuffer); - return create_test_case_from_config( - config, - vkapi::kFloat, - fp_storage_type, - utils::kPackedInt8_4W4C, - impl_selector, - &options); -} - -std::vector generate_im2col_test_cases( - const std::vector>& - configs, - const std::vector& impl_selectors, - const std::vector& auto_configs, - const std::vector& buffer_output_configs) { - const vkapi::Adapter& adapter = *vkcompute::api::context()->adapter_ptr(); - std::vector test_cases; - for (const std::string& impl_selector : impl_selectors) { - if (impl_selector == "im2col_unsigned" && - !adapter.supports_int8_dot_product()) { - continue; - } - - for (const auto& [config, options] : configs) { - test_cases.push_back(create_im2col_test_case( - config, utils::kTexture3D, impl_selector, options)); - } - - if (impl_selector == "im2col_auto") { - for (const Conv2dConfig& config : auto_configs) { - test_cases.push_back(create_im2col_test_case( - config, - utils::kTexture3D, - impl_selector, - Im2colUnsignedTestOptions{})); - } - } - - if (impl_selector == "im2col_unsigned" || - (impl_selector == "im2col_auto" && - can_use_unsigned_pw_dot(adapter, 4))) { - for (Conv2dConfig config : buffer_output_configs) { - config.channels.out = utils::safe_downcast( - static_cast(adapter.max_texture2d_dim()) * 4 + - config.channels.out); - test_cases.push_back(create_im2col_test_case( - config, - utils::kBuffer, - impl_selector, - Im2colUnsignedTestOptions{})); - } - } - } - return test_cases; -} - -} // namespace - -REGISTER_TEST_CASE_SET("q8ta/conv2d", "im2col") { - const std::vector> - configs = { - {{OutInChannels(5, 4), - InputSize2D(5, 5), - KernelSize(3, 3), - Stride(1, 1), - Padding(0, 0), - Dilation(1, 1), - 1}, - {.input_zero_point = 2, - .output_zero_point = -1, - .use_extreme_values = true, - .use_accumulator_limit_values = false, - .has_bias = true, - .activation = "none"}}, - {{OutInChannels(8, 8), - InputSize2D(5, 5), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 1}, - {.input_zero_point = -7, - .output_zero_point = 3, - .use_extreme_values = true, - .use_accumulator_limit_values = false, - .has_bias = false, - .activation = "none"}}, - {{OutInChannels(12, 8), - InputSize2D(7, 7), - KernelSize(3, 3), - Stride(2, 2), - Padding(1, 1), - Dilation(1, 1), - 1}, - {.input_zero_point = 127, - .output_zero_point = -5, - .use_extreme_values = true, - .use_accumulator_limit_values = false, - .has_bias = true, - .activation = "relu"}}, - {{OutInChannels(12, 8), - InputSize2D(9, 9), - KernelSize(3, 3), - Stride(1, 1), - Padding(2, 2), - Dilation(2, 2), - 1}, - {.input_zero_point = -128, - .output_zero_point = 5, - .use_extreme_values = true, - .use_accumulator_limit_values = false, - .has_bias = false, - .activation = "none"}}, - {{OutInChannels(8, 8), - InputSize2D(6, 7), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 2}, - {.input_zero_point = 11, - .output_zero_point = -3, - .use_extreme_values = true, - .use_accumulator_limit_values = false, - .has_bias = true, - .activation = "relu"}}, - {{OutInChannels(1, 4), - InputSize2D(90, 91), - KernelSize(90, 91), - Stride(1, 1), - Padding(0, 0), - Dilation(1, 1), - 1}, - {.input_zero_point = 0, - .output_zero_point = -1, - .use_extreme_values = false, - .use_accumulator_limit_values = true, - .has_bias = true, - .activation = "none", - .weight_scale = 1.0f / 1000000.0f}}, - }; - const std::vector impl_selectors = { - "im2col", "im2col_unsigned", "im2col_auto"}; - const std::vector auto_configs = { - {OutInChannels(1, 4), - InputSize2D(91, 91), - KernelSize(91, 91), - Stride(1, 1), - Padding(0, 0), - Dilation(1, 1), - 1}, - }; - // Output channels are offset by max_texture2d_dim * 4 so that the output - // exceeds the texture limit and uses buffer storage. - const std::vector buffer_output_configs = { - {OutInChannels(1, 4), - InputSize2D(1, 1), - KernelSize(1, 1), - Stride(1, 1), - Padding(0, 0), - Dilation(1, 1), - 1}, - }; - return { - generate_im2col_test_cases( - configs, impl_selectors, auto_configs, buffer_output_configs), - reference_impl, - quantized_conv2d_flop_calculator}; -} - -} // namespace q8ta_conv2d -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/narrow_workgroup_set.cpp b/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/narrow_workgroup_set.cpp deleted file mode 100644 index 896b9ff66e5..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/narrow_workgroup_set.cpp +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include -#include - -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8ta_conv2d { - -namespace { - -std::vector generate_narrow_workgroup_test_cases( - const std::vector& configs) { - std::vector test_cases; - for (Conv2dConfig config : configs) { - const bool is_performance = config.channels.out > kRefDimSizeLimit; - config.op_name = "conv2d_q8ta_q8csw_q8to"; - config.test_case_name = make_test_case_name( - config, is_performance, utils::kTexture3D, utils::kBuffer); - test_cases.push_back(create_test_case_from_config( - config, - vkapi::kFloat, - utils::kTexture3D, - utils::kPackedInt8_4C, - /*impl_selector=*/"general")); - } - return test_cases; -} - -} // namespace - -REGISTER_TEST_CASE_SET("q8ta/conv2d", "narrow_workgroup") { - const std::vector configs = { - {OutInChannels(64, 32), - InputSize2D(9, 9), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 1}, - {OutInChannels(128, 32), - InputSize2D(7, 7), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 1}, - }; - return { - generate_narrow_workgroup_test_cases(configs), - reference_impl, - quantized_conv2d_flop_calculator}; -} - -} // namespace q8ta_conv2d -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/pw_sets.cpp b/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/pw_sets.cpp deleted file mode 100644 index 046e8f6d271..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/pw_sets.cpp +++ /dev/null @@ -1,294 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include -#include - -#include -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8ta_conv2d { - -namespace { - -// Generate test cases for quantized pointwise conv2d operation. A non-empty pw -// selector lets the test graph builder force or verify the unsigned-dot -// routing instead of the default automatic path. -std::vector generate_quantized_conv2d_pw_test_cases( - const std::vector& configs, - const std::vector& batch_configs, - const std::vector>& - edge_configs, - const std::vector& fp_storage_types, - const std::vector& int8_memory_layouts, - const std::vector& pw_selectors) { - std::vector test_cases; - if (!vkcompute::api::context()->adapter_ptr()->supports_int8_dot_product()) { - return test_cases; - } - - // Generate test cases for each combination - for (Conv2dConfig config : configs) { - bool is_performance = config.channels.out > kRefDimSizeLimit || - config.channels.in > kRefDimSizeLimit || - config.input_size.h > kRefDimSizeLimit || - config.input_size.w > kRefDimSizeLimit; - - config.op_name = "conv2d_q8ta_q8csw_q8to"; - - for (const utils::StorageType fp_storage_type : fp_storage_types) { - for (const utils::GPUMemoryLayout int8_memory_layout : - int8_memory_layouts) { - config.test_case_name = make_test_case_name( - config, is_performance, fp_storage_type, utils::kBuffer); - for (const std::string& pw_selector : pw_selectors) { - test_cases.push_back(create_pw_test_case_from_config( - config, - vkapi::kFloat, - fp_storage_type, - int8_memory_layout, - pw_selector)); - } - - // For 4W4C layout, also test the legacy implementation - if (int8_memory_layout == utils::kPackedInt8_4W4C) { - test_cases.push_back(create_pw_test_case_from_config( - config, - vkapi::kFloat, - fp_storage_type, - int8_memory_layout, - /*impl_selector=*/"legacy_4w4c")); - } - } - } - } - - for (Conv2dConfig config : batch_configs) { - const bool is_performance = config.input_size.h > kRefDimSizeLimit || - config.input_size.w > kRefDimSizeLimit; - config.op_name = "conv2d_q8ta_q8csw_q8to"; - config.test_case_name = make_test_case_name( - config, is_performance, utils::kTexture3D, utils::kBuffer); - for (const std::string& pw_selector : pw_selectors) { - test_cases.push_back(create_pw_test_case_from_config( - config, - vkapi::kFloat, - utils::kTexture3D, - utils::kPackedInt8_4C1W, - pw_selector)); - if (config.batch == 2) { - test_cases.push_back(create_pw_test_case_from_config( - config, - vkapi::kFloat, - utils::kTexture3D, - utils::kPackedInt8_4W4C, - pw_selector)); - } - } - } - - for (const auto& [edge_config, options] : edge_configs) { - Conv2dConfig config = edge_config; - config.op_name = "conv2d_q8ta_q8csw_q8to"; - config.test_case_name = - make_test_case_name(config, false, utils::kTexture3D, utils::kBuffer); - for (const std::string& pw_selector : pw_selectors) { - test_cases.push_back(create_pw_test_case_from_config( - config, - vkapi::kFloat, - utils::kTexture3D, - utils::kPackedInt8_4W4C, - pw_selector, - options)); - } - } - - return test_cases; -} - -} // namespace - -REGISTER_TEST_CASE_SET("q8ta/conv2d", "pw_correctness") { - const std::vector configs = { - // OC < 4 cases to test edge cases with partial output channel blocks - {OutInChannels(1, 16), - InputSize2D(8, 8), - KernelSize(1, 1), - Stride(1, 1), - Padding(0, 0), - Dilation(1, 1), - 1}, - {OutInChannels(2, 16), - InputSize2D(8, 8), - KernelSize(1, 1), - Stride(1, 1), - Padding(0, 0), - Dilation(1, 1), - 1}, - {OutInChannels(3, 16), - InputSize2D(8, 8), - KernelSize(1, 1), - Stride(1, 1), - Padding(0, 0), - Dilation(1, 1), - 1}, - // Pointwise convolutions: kernel size 1x1 - {OutInChannels(32, 3), - InputSize2D(64, 64), - KernelSize(1, 1), - Stride(1, 1), - Padding(0, 0), - Dilation(1, 1), - 1}, - {OutInChannels(64, 32), - InputSize2D(32, 32), - KernelSize(1, 1), - Stride(1, 1), - Padding(0, 0), - Dilation(1, 1), - 1}, - {OutInChannels(96, 64), - InputSize2D(16, 16), - KernelSize(1, 1), - Stride(1, 1), - Padding(0, 0), - Dilation(1, 1), - 1}, - {OutInChannels(13, 7), - InputSize2D(57, 33), - KernelSize(1, 1), - Stride(1, 1), - Padding(0, 0), - Dilation(1, 1), - 1}, - {OutInChannels(80, 40), - InputSize2D(64, 64), - KernelSize(1, 1), - Stride(1, 1), - Padding(0, 0), - Dilation(1, 1), - 1}, - }; - const std::vector batch_configs = { - {OutInChannels(8, 8), - InputSize2D(8, 8), - KernelSize(1, 1), - Stride(1, 1), - Padding(0, 0), - Dilation(1, 1), - 1, - 2}, - }; - const std::vector> - edge_configs = { - {{OutInChannels(13, 7), - InputSize2D(7, 5), - KernelSize(1, 1), - Stride(1, 1), - Padding(0, 0), - Dilation(1, 1), - 1}, - {.input_zero_point = -128, .has_bias = false}}, - }; - const std::vector fp_storage_types = {utils::kTexture3D}; - const std::vector int8_memory_layouts = { - utils::kPackedInt8_4C1W, utils::kPackedInt8_4W4C, utils::kPackedInt8_4C}; - const std::vector pw_selectors = { - "", "pw_signed", "pw_unsigned", "pw_auto"}; - return { - generate_quantized_conv2d_pw_test_cases( - configs, - batch_configs, - edge_configs, - fp_storage_types, - int8_memory_layouts, - pw_selectors), - pw_reference_impl, - quantized_conv2d_flop_calculator}; -} - -REGISTER_TEST_CASE_SET("q8ta/conv2d", "pw_performance") { - const std::vector configs = { - // Performance cases (pointwise - will use im2col) - {OutInChannels(160, 480), - InputSize2D(8, 8), - KernelSize(1, 1), - Stride(1, 1), - Padding(0, 0), - Dilation(1, 1), - 1}, - {OutInChannels(22, 48), - InputSize2D(256, 256), - KernelSize(1, 1), - Stride(1, 1), - Padding(0, 0), - Dilation(1, 1), - 1}, - {OutInChannels(48, 48), - InputSize2D(128, 128), - KernelSize(1, 1), - Stride(1, 1), - Padding(0, 0), - Dilation(1, 1), - 1}, - {OutInChannels(128, 128), - InputSize2D(128, 128), - KernelSize(1, 1), - Stride(1, 1), - Padding(0, 0), - Dilation(1, 1), - 1}, - {OutInChannels(64, 576), - InputSize2D(128, 128), - KernelSize(1, 1), - Stride(1, 1), - Padding(0, 0), - Dilation(1, 1), - 1}, - }; - const std::vector batch_configs = { - {OutInChannels(64, 32), - InputSize2D(128, 128), - KernelSize(1, 1), - Stride(1, 1), - Padding(0, 0), - Dilation(1, 1), - 1, - 1}, - {OutInChannels(64, 32), - InputSize2D(128, 128), - KernelSize(1, 1), - Stride(1, 1), - Padding(0, 0), - Dilation(1, 1), - 1, - 60}, - }; - const std::vector fp_storage_types = {utils::kTexture3D}; - const std::vector int8_memory_layouts = { - utils::kPackedInt8_4C1W, utils::kPackedInt8_4W4C, utils::kPackedInt8_4C}; - const std::vector pw_selectors = { - "", "pw_signed", "pw_unsigned", "pw_auto"}; - return { - generate_quantized_conv2d_pw_test_cases( - configs, - batch_configs, - /*edge_configs=*/{}, - fp_storage_types, - int8_memory_layouts, - pw_selectors), - pw_reference_impl, - quantized_conv2d_flop_calculator}; -} - -} // namespace q8ta_conv2d -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/scenex_sets.cpp b/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/scenex_sets.cpp deleted file mode 100644 index 709b6f40c0e..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/scenex_sets.cpp +++ /dev/null @@ -1,101 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include -#include - -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8ta_conv2d { - -namespace { - -std::vector generate_scenex_test_cases( - const std::vector& configs, - const std::vector& routes) { - std::vector test_cases; - for (const std::string& route : routes) { - for (const Conv2dConfig& config : configs) { - test_cases.push_back(create_scenex_test_case(config, route)); - } - } - return test_cases; -} - -} // namespace - -REGISTER_TEST_CASE_SET("q8ta/conv2d", "scenex_regular") { - const std::vector configs = { - {OutInChannels(128, 64), - InputSize2D(40, 51), - KernelSize(3, 3), - Stride(2, 2), - Padding(1, 1), - Dilation(1, 1), - 1, - 60}, - {OutInChannels(256, 128), - InputSize2D(20, 26), - KernelSize(3, 3), - Stride(2, 2), - Padding(1, 1), - Dilation(1, 1), - 1, - 60}, - }; - const std::vector routes = {"auto", "direct", "im2col"}; - return { - generate_scenex_test_cases(configs, routes), - scenex_direct_reference, - quantized_conv2d_flop_calculator, - /*warmup_runs=*/3, - /*benchmark_runs=*/10}; -} - -REGISTER_TEST_CASE_SET("q8ta/conv2d", "scenex_grouped") { - const std::vector configs = { - {OutInChannels(64, 64), - InputSize2D(128, 128), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 2, - 60}, - {OutInChannels(128, 128), - InputSize2D(128, 128), - KernelSize(5, 5), - Stride(2, 2), - Padding(2, 2), - Dilation(1, 1), - 4, - 60}, - {OutInChannels(64, 64), - InputSize2D(64, 64), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 2, - 60}, - }; - const std::vector routes = {"auto", "direct", "im2col"}; - return { - generate_scenex_test_cases(configs, routes), - scenex_direct_reference, - quantized_conv2d_flop_calculator, - /*warmup_runs=*/3, - /*benchmark_runs=*/10}; -} - -} // namespace q8ta_conv2d -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/streaming_dynamic_shrink_set.cpp b/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/streaming_dynamic_shrink_set.cpp deleted file mode 100644 index c9354b017a6..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/streaming_dynamic_shrink_set.cpp +++ /dev/null @@ -1,128 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include -#include - -#include -#include - -#include -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8ta_conv2d { - -namespace { - -void execute_streaming_dynamic_shrink_test() { - Conv2dConfig config = { - OutInChannels(4, 64), - InputSize2D(30, 99), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 1, - 10}; - config.op_name = "conv2d_q8ta_q8csw_q8to"; - config.test_case_name = - make_test_case_name(config, false, utils::kTexture3D, utils::kBuffer); - TestCase test_case = create_test_case_from_config( - config, - vkapi::kFloat, - utils::kTexture3D, - utils::kPackedInt8_4W4C, - /*impl_selector=*/"im2col_auto", - /*im2col_options=*/nullptr, - /*input_scale_val=*/1.0f, - /*input_data_gen=*/DataGenType::RANDINT); - for (ValueSpec& input : test_case.inputs()) { - input.ensure_data_generated(/*seed=*/0); - } - - BenchmarkGraph benchmark_graph = setup_compute_graph( - test_case, test_case.operator_name(), /*op_invocations_per_execute=*/1); - ComputeGraph& graph = *benchmark_graph.graph; - graph.prepare(); - graph.prepack(); - - const std::vector upper_input_sizes = test_case.inputs().at(0).sizes; - const std::vector shrunk_input_sizes = {1, 64, 20, 51}; - const std::vector shrunk_output_sizes = {1, 4, 20, 51}; - TestCase shrunk_case = test_case; - shrunk_case.inputs().at(0).sizes = shrunk_input_sizes; - shrunk_case.inputs().at(0).resize_data(1 * 64 * 20 * 51); - shrunk_case.outputs().at(0).sizes = shrunk_output_sizes; - shrunk_case.outputs().at(0).resize_data(1 * 4 * 20 * 51); - reference_impl(shrunk_case); - - constexpr int kRepetitions = 4; - for (int repetition = 0; repetition < kRepetitions; ++repetition) { - graph.resize_input(0, upper_input_sizes); - graph.propagate_resize(); - graph.maybe_cast_and_copy_into_staging( - graph.inputs().at(0).staging, - test_case.inputs().at(0).get_data_ptr(), - test_case.inputs().at(0).numel(), - vkapi::kFloat); - graph.execute(); - - graph.resize_input(0, shrunk_input_sizes); - graph.propagate_resize(); - if (graph.sizes_of(graph.outputs().at(0).value) != shrunk_output_sizes) { - throw std::runtime_error("streaming im2col output did not shrink"); - } - graph.maybe_cast_and_copy_into_staging( - graph.inputs().at(0).staging, - shrunk_case.inputs().at(0).get_data_ptr(), - shrunk_case.inputs().at(0).numel(), - vkapi::kFloat); - graph.execute(); - graph.maybe_cast_and_copy_from_staging( - graph.outputs().at(0).staging, - shrunk_case.outputs().at(0).get_mutable_data_ptr(), - shrunk_case.outputs().at(0).numel(), - vkapi::kFloat); - if (!shrunk_case.outputs().at(0).validate_against_reference( - shrunk_case.get_abs_tolerance(), shrunk_case.get_rel_tolerance())) { - throw std::runtime_error("streaming im2col shrink output was stale"); - } - } - - const Q8taConv2dStreamPlan upper_plan = make_q8ta_conv2d_stream_plan( - /*batch=*/10, - /*flattened_kernel_size=*/576, - /*out_height=*/30, - /*out_width=*/99, - kQ8taConv2dIm2ColScratchBudgetBytes); - if (!upper_plan.feasible || upper_plan.num_tiles != 2 || - upper_plan.rows_per_tile <= 20) { - throw std::runtime_error("dynamic shrink test did not create dead tiles"); - } - const int64_t resized_scratch_bytes = - 576 * upper_plan.rows_per_tile * utils::align_up_4(51); - if (resized_scratch_bytes > kQ8taConv2dIm2ColScratchBudgetBytes) { - throw std::runtime_error("streaming im2col scratch exceeded its cap"); - } - std::cout << "Streaming im2col dynamic shrink PASSED" << std::endl; -} - -} // namespace - -REGISTER_TEST_CASE_SET("q8ta/conv2d", "streaming_dynamic_shrink") { - TestCaseSet set; - set.custom_test = execute_streaming_dynamic_shrink_test; - return set; -} - -} // namespace q8ta_conv2d -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/streaming_im2col_set.cpp b/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/streaming_im2col_set.cpp deleted file mode 100644 index b94fbc25140..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/streaming_im2col_set.cpp +++ /dev/null @@ -1,138 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include -#include - -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8ta_conv2d { - -namespace { - -struct StreamingIm2colConfig { - Conv2dConfig config; - std::vector int8_memory_layouts; - std::string impl_selector; -}; - -std::vector generate_streaming_im2col_test_cases( - const std::vector& configs) { - const bool has_int8_dot_product = - vkcompute::api::context()->adapter_ptr()->supports_int8_dot_product(); - std::vector test_cases; - for (const StreamingIm2colConfig& streaming_config : configs) { - // Forced-fallback cases need no int8 dot-product support, so they run on - // all devices; the other routes stay gated. - if (!has_int8_dot_product && - streaming_config.impl_selector != "im2col_fallback") { - continue; - } - Conv2dConfig config = streaming_config.config; - config.op_name = "conv2d_q8ta_q8csw_q8to"; - config.test_case_name = - make_test_case_name(config, false, utils::kTexture3D, utils::kBuffer); - for (const utils::GPUMemoryLayout int8_memory_layout : - streaming_config.int8_memory_layouts) { - test_cases.push_back(create_test_case_from_config( - config, - vkapi::kFloat, - utils::kTexture3D, - int8_memory_layout, - streaming_config.impl_selector, - /*im2col_options=*/nullptr, - /*input_scale_val=*/1.0f, - /*input_data_gen=*/DataGenType::RANDINT)); - } - } - return test_cases; -} - -} // namespace - -REGISTER_TEST_CASE_SET("q8ta/conv2d", "streaming_im2col") { - const std::vector configs = { - // Full fit - {{OutInChannels(4, 32), - InputSize2D(30, 99), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 1, - 10}, - {utils::kPackedInt8_4W4C}, - "im2col_fallback"}, - // Streaming - {{OutInChannels(4, 64), - InputSize2D(30, 99), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 1, - 10}, - {utils::kPackedInt8_4W4C}, - "im2col_fallback"}, - // Full fit - {{OutInChannels(4, 32), - InputSize2D(30, 99), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 1, - 10}, - {utils::kPackedInt8_4C1W, utils::kPackedInt8_4W4C}, - "im2col_auto"}, - // Streaming - {{OutInChannels(4, 64), - InputSize2D(30, 99), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 1, - 10}, - {utils::kPackedInt8_4C1W}, - "im2col_auto"}, - // Grouped - {{OutInChannels(16, 32), - InputSize2D(30, 99), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 2, - 20}, - {utils::kPackedInt8_4W4C}, - "im2col_auto"}, - // Output channel tail - {{OutInChannels(10, 32), - InputSize2D(30, 99), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 1, - 20}, - {utils::kPackedInt8_4W4C}, - "im2col_auto"}, - }; - return { - generate_streaming_im2col_test_cases(configs), - reference_impl, - quantized_conv2d_flop_calculator}; -} - -} // namespace q8ta_conv2d -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/unsigned_dot_selection_set.cpp b/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/unsigned_dot_selection_set.cpp deleted file mode 100644 index 0f568ad30c9..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d/test_sets/unsigned_dot_selection_set.cpp +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include -#include - -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8ta_conv2d { - -namespace { - -void check_unsigned_dot_selection() { - const vkapi::Adapter& adapter = *vkcompute::api::context()->adapter_ptr(); - const bool prefers_unsigned_dot = - adapter.accelerates_unsigned_packed4x8_dot() && - !adapter.accelerates_signed_packed4x8_dot(); - VK_CHECK_COND( - can_use_unsigned_pw_dot(adapter, kMaxUnsignedDotAccumulatorBytes) == - prefers_unsigned_dot); - VK_CHECK_COND( - !can_use_unsigned_pw_dot(adapter, kMaxUnsignedDotAccumulatorBytes + 1)); -} - -} // namespace - -REGISTER_TEST_CASE_SET("q8ta/conv2d", "unsigned_dot_selection") { - TestCaseSet set; - set.custom_test = check_unsigned_dot_selection; - return set; -} - -} // namespace q8ta_conv2d -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d_transposed/conv2d_transposed.cpp b/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d_transposed/conv2d_transposed.cpp deleted file mode 100644 index 18c2cd16835..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d_transposed/conv2d_transposed.cpp +++ /dev/null @@ -1,422 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include -#include -#include -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8ta_conv2d_transposed { - -namespace { - -// Transposed convolution output size formula: -// H_out = (H_in - 1) * stride_h - 2 * pad_h + dilation_h * (K_h - 1) -// + output_pad_h + 1 -int64_t get_transpose_output_height( - const Conv2dConfig& config, - int32_t output_pad_h) { - return (config.input_size.h - 1) * config.stride.h - 2 * config.padding.h + - config.dilation.h * (config.kernel.h - 1) + output_pad_h + 1; -} - -int64_t get_transpose_output_width( - const Conv2dConfig& config, - int32_t output_pad_w) { - return (config.input_size.w - 1) * config.stride.w - 2 * config.padding.w + - config.dilation.w * (config.kernel.w - 1) + output_pad_w + 1; -} - -} // namespace - -// Utility function to create a test case from a Conv2dConfig for transposed -// convolution -TestCase create_test_case_from_config( - const Conv2dConfig& config, - int32_t output_pad_h, - int32_t output_pad_w, - vkapi::ScalarType input_dtype, - utils::StorageType fp_storage_type, - utils::GPUMemoryLayout int8_memory_layout) { - TestCase test_case; - - int64_t H_out = get_transpose_output_height(config, output_pad_h); - int64_t W_out = get_transpose_output_width(config, output_pad_w); - - // Input tensor (float/half) - [1, C_in, H_in, W_in] (batch size always 1) - // For transposed conv, C_in is typically larger (downsampled channels) - std::vector input_size = { - 1, config.channels.in, config.input_size.h, config.input_size.w}; - - utils::GPUMemoryLayout fp_memory_layout = fp_storage_type == utils::kBuffer - ? utils::kWidthPacked - : utils::kChannelsPacked; - - // Create test case name - std::string prefix = config.test_case_name.substr(0, 4); - std::string dtype_str = dtype_short(input_dtype); - std::string in_shape = "[1," + std::to_string(config.channels.in) + "," + - std::to_string(config.input_size.h) + "," + - std::to_string(config.input_size.w) + "]"; - std::string weight_shape = "[" + std::to_string(config.channels.out) + "," + - std::to_string(config.channels.in / config.groups) + "," + - std::to_string(config.kernel.h) + "," + std::to_string(config.kernel.w) + - "]"; - std::string shape_str = in_shape + "x" + weight_shape + " s" + - std::to_string(config.stride.h) + " p" + - std::to_string(config.padding.h) + " d" + - std::to_string(config.dilation.h) + " g" + std::to_string(config.groups) + - " op=" + std::to_string(output_pad_h) + "," + - std::to_string(output_pad_w); - std::string storage_str = repr_str(utils::kBuffer, int8_memory_layout); - std::string test_name = - make_test_label(prefix, dtype_str, dtype_str, shape_str, storage_str); - test_case.set_name(test_name); - - test_case.set_operator_name("test_etvk.test_q8ta_conv2d_transposed.default"); - - ValueSpec input_tensor( - input_size, - input_dtype, - fp_storage_type, - fp_memory_layout, - DataGenType::RANDOM); - - if (debugging()) { - print_valuespec_data(input_tensor, "input_tensor"); - } - - float input_scale_val = 0.008123; - ValueSpec input_scale(input_scale_val); - - int32_t input_zero_point_val = 2; - ValueSpec input_zero_point(input_zero_point_val); - - // Quantized weight tensor (int8) - [C_out, align_up_4(C_in_per_group * K_h * - // K_w)] After the pattern matcher reshapes, the transposed conv weight has - // the same layout as regular conv2d - const int64_t in_channels_per_group = config.channels.in / config.groups; - const int64_t in_features = utils::align_up_4( - in_channels_per_group * config.kernel.h * config.kernel.w); - std::vector weight_size = {config.channels.out, in_features}; - ValueSpec quantized_weight( - weight_size, - vkapi::kChar, - fp_storage_type, - utils::kWidthPacked, - DataGenType::RANDINT8); - quantized_weight.set_constant(true); - - if (debugging()) { - print_valuespec_data(quantized_weight, "weight_tensor"); - } - - const int64_t aligned_out_channels = utils::align_up_4(config.channels.out); - - ValueSpec weight_scales( - {aligned_out_channels}, - input_dtype, - fp_storage_type, - utils::kWidthPacked, - DataGenType::RANDOM_SCALES); - weight_scales.set_constant(true); - - ValueSpec weight_sums( - {aligned_out_channels}, - vkapi::kInt, - fp_storage_type, - utils::kWidthPacked, - DataGenType::ZEROS); - weight_sums.set_constant(true); - - compute_weight_sums( - weight_sums, quantized_weight, config.channels.out, in_features); - - ValueSpec bias( - {aligned_out_channels}, - input_dtype, - fp_storage_type, - utils::kWidthPacked, - DataGenType::ZEROS); - bias.set_constant(true); - - float output_scale_val = 0.05314; - ValueSpec output_scale(output_scale_val); - - int32_t output_zero_point_val = -1; - ValueSpec output_zero_point(output_zero_point_val); - - ValueSpec stride({config.stride.h, config.stride.w}); - ValueSpec padding({config.padding.h, config.padding.w}); - ValueSpec output_padding({output_pad_h, output_pad_w}); - ValueSpec dilation({config.dilation.h, config.dilation.w}); - ValueSpec groups(config.groups); - ValueSpec kernel_size({config.kernel.h, config.kernel.w}); - - // Output tensor - [1, C_out, H_out, W_out] - ValueSpec output( - {1, config.channels.out, H_out, W_out}, - input_dtype, - fp_storage_type, - fp_memory_layout, - DataGenType::ZEROS); - - // Add all specs to test case - test_case.add_input_spec(input_tensor); - test_case.add_input_spec(input_scale); - test_case.add_input_spec(input_zero_point); - test_case.add_input_spec(quantized_weight); - test_case.add_input_spec(weight_sums); - test_case.add_input_spec(weight_scales); - test_case.add_input_spec(output_scale); - test_case.add_input_spec(output_zero_point); - test_case.add_input_spec(bias); - test_case.add_input_spec(kernel_size); - test_case.add_input_spec(stride); - test_case.add_input_spec(padding); - test_case.add_input_spec(output_padding); - test_case.add_input_spec(dilation); - test_case.add_input_spec(groups); - - ValueSpec activation = ValueSpec::make_string("none"); - test_case.add_input_spec(activation); - - ValueSpec layout_int(static_cast(int8_memory_layout)); - test_case.add_input_spec(layout_int); - - test_case.add_output_spec(output); - - test_case.set_abs_tolerance(output_scale_val + 1e-4f); - - test_case.set_shader_filter({ - "nchw_to", - "to_nchw", - "q8ta_quantize", - "q8ta_dequantize", - }); - - return test_case; -} - -int64_t quantized_conv2d_transposed_flop_calculator(const TestCase& test_case) { - int kernel_idx = 9; - - const auto& input_sizes = test_case.inputs()[0].get_tensor_sizes(); - const auto& output_sizes = test_case.outputs()[0].get_tensor_sizes(); - const auto& kernel_sizes = test_case.inputs()[kernel_idx].get_int32_data(); - - int64_t N = input_sizes[0]; - int64_t C_in = input_sizes[1]; - int64_t C_out = output_sizes[1]; - int64_t K_h = kernel_sizes[0]; - int64_t K_w = kernel_sizes[1]; - int64_t H_out = output_sizes[2]; - int64_t W_out = output_sizes[3]; - - int64_t output_elements = N * C_out * H_out * W_out; - int64_t ops_per_output = C_in * K_h * K_w; - - return output_elements * ops_per_output; -} - -namespace { - -// Reference implementation for quantized transposed conv2d -void conv2d_transposed_q8ta_reference_impl(TestCase& test_case) { - int32_t idx = 0; - const ValueSpec& input_spec = test_case.inputs()[idx++]; - const ValueSpec& input_scale_spec = test_case.inputs()[idx++]; - const ValueSpec& input_zeros_spec = test_case.inputs()[idx++]; - const ValueSpec& weight_spec = test_case.inputs()[idx++]; - const ValueSpec& weight_sums_spec = test_case.inputs()[idx++]; - (void)weight_sums_spec; - const ValueSpec& weight_scales_spec = test_case.inputs()[idx++]; - const ValueSpec& output_scale_spec = test_case.inputs()[idx++]; - const ValueSpec& output_zeros_spec = test_case.inputs()[idx++]; - const ValueSpec& bias_spec = test_case.inputs()[idx++]; - const ValueSpec& kernel_size_spec = test_case.inputs()[idx++]; - const ValueSpec& stride_spec = test_case.inputs()[idx++]; - const ValueSpec& padding_spec = test_case.inputs()[idx++]; - const ValueSpec& output_padding_spec = test_case.inputs()[idx++]; - (void)output_padding_spec; // output_padding only affects output size - const ValueSpec& dilation_spec = test_case.inputs()[idx++]; - const ValueSpec& groups_spec = test_case.inputs()[idx++]; - const ValueSpec& activation_spec = test_case.inputs()[idx++]; - (void)activation_spec; - const ValueSpec& layout_spec = test_case.inputs()[idx++]; - (void)layout_spec; - - ValueSpec& output_spec = test_case.outputs()[0]; - - auto input_sizes = input_spec.get_tensor_sizes(); - auto output_sizes = output_spec.get_tensor_sizes(); - - int64_t N = input_sizes[0]; - int64_t C_in = input_sizes[1]; - int64_t H_in = input_sizes[2]; - int64_t W_in = input_sizes[3]; - int64_t C_out = output_sizes[1]; - int64_t H_out = output_sizes[2]; - int64_t W_out = output_sizes[3]; - - auto kernel_size_data = kernel_size_spec.get_int32_data(); - int64_t K_h = kernel_size_data[0]; - int64_t K_w = kernel_size_data[1]; - - auto stride_data = stride_spec.get_int32_data(); - auto padding_data = padding_spec.get_int32_data(); - auto dilation_data = dilation_spec.get_int32_data(); - int64_t stride_h = stride_data[0]; - int64_t stride_w = stride_data[1]; - int64_t pad_h = padding_data[0]; - int64_t pad_w = padding_data[1]; - int64_t dilation_h = dilation_data[0]; - int64_t dilation_w = dilation_data[1]; - int64_t groups = groups_spec.get_int_value(); - - if (N > kRefDimSizeLimit || C_in > kRefDimSizeLimit || - H_in > kRefDimSizeLimit || W_in > kRefDimSizeLimit || - C_out > kRefDimSizeLimit) { - throw std::invalid_argument( - "One or more dimensions exceed the allowed limit for reference implementation."); - } - - if (input_spec.dtype != vkapi::kFloat) { - throw std::invalid_argument("Unsupported dtype"); - } - - auto& input_data = input_spec.get_float_data(); - const float input_scale = input_scale_spec.get_float_value(); - const int32_t input_zero_point = input_zeros_spec.get_int_value(); - - auto& weight_data = weight_spec.get_int8_data(); - auto& weight_scales_data = weight_scales_spec.get_float_data(); - auto& bias_data = bias_spec.get_float_data(); - - const float output_scale = output_scale_spec.get_float_value(); - const int32_t output_zero_point = output_zeros_spec.get_int_value(); - - int64_t C_in_per_group = C_in / groups; - int64_t C_out_per_group = C_out / groups; - - int64_t num_output_elements = N * C_out * H_out * W_out; - - auto& ref_data = output_spec.get_ref_float_data(); - ref_data.resize(num_output_elements); - - const int64_t in_features = utils::align_up_4(C_in_per_group * K_h * K_w); - - // Transposed convolution reference implementation. - // For transposed conv, we scatter each input element across the output - // rather than gather. But for the reference we compute it by iterating - // over output positions and finding which input positions contribute. - // - // For each output position (oh, ow), an input position (iy, ix) contributes - // via kernel position (kh, kw) if: - // oh + pad_h - kh * dilation_h == iy * stride_h - // ow + pad_w - kw * dilation_w == ix * stride_w - // i.e., (oh + pad_h - kh * dilation_h) must be divisible by stride_h - // and the quotient must be a valid input index. - for (int64_t n = 0; n < N; ++n) { - for (int64_t out_c = 0; out_c < C_out; ++out_c) { - int64_t group_idx = out_c / C_out_per_group; - int64_t in_c_start = group_idx * C_in_per_group; - - for (int64_t out_h = 0; out_h < H_out; ++out_h) { - for (int64_t out_w = 0; out_w < W_out; ++out_w) { - int32_t int_sum = 0; - int32_t weight_sum = 0; - - for (int64_t kh = 0; kh < K_h; ++kh) { - int64_t h_offset = out_h + pad_h - kh * dilation_h; - if (h_offset < 0 || h_offset % stride_h != 0) { - continue; - } - int64_t iy = h_offset / stride_h; - if (iy >= H_in) { - continue; - } - - for (int64_t kw = 0; kw < K_w; ++kw) { - int64_t w_offset = out_w + pad_w - kw * dilation_w; - if (w_offset < 0 || w_offset % stride_w != 0) { - continue; - } - int64_t ix = w_offset / stride_w; - if (ix >= W_in) { - continue; - } - - for (int64_t ic_local = 0; ic_local < C_in_per_group; - ++ic_local) { - int64_t in_c = in_c_start + ic_local; - - int64_t input_idx = n * (C_in * H_in * W_in) + - in_c * (H_in * W_in) + iy * W_in + ix; - - float quant_input_f = - std::round(input_data[input_idx] / input_scale) + - input_zero_point; - quant_input_f = - std::min(std::max(quant_input_f, -128.0f), 127.0f); - int8_t quantized_input = static_cast(quant_input_f); - - // Weight layout: [C_out, align_up_4(C_in_per_group * K_h * - // K_w)] Inner dimension order: kh, kw, ic_local - int64_t weight_idx = out_c * in_features + - (kh * (K_w * C_in_per_group) + kw * C_in_per_group + - ic_local); - int8_t quantized_weight = weight_data[weight_idx]; - - int_sum += static_cast(quantized_input) * - static_cast(quantized_weight); - - weight_sum += static_cast(quantized_weight); - } - } - } - - int32_t zero_point_correction = input_zero_point * weight_sum; - int32_t accum_adjusted = int_sum - zero_point_correction; - float float_result = - accum_adjusted * input_scale * weight_scales_data[out_c]; - - float_result += bias_data[out_c]; - - float quant_output_f = - std::round(float_result / output_scale) + output_zero_point; - quant_output_f = std::min(std::max(quant_output_f, -128.0f), 127.0f); - int8_t quantized_output = static_cast(quant_output_f); - - float dequant_output = - (static_cast(quantized_output) - output_zero_point) * - output_scale; - - int64_t output_idx = n * (C_out * H_out * W_out) + - out_c * (H_out * W_out) + out_h * W_out + out_w; - ref_data[output_idx] = dequant_output; - } - } - } - } -} - -} // namespace - -void reference_impl(TestCase& test_case) { - conv2d_transposed_q8ta_reference_impl(test_case); -} - -} // namespace q8ta_conv2d_transposed -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d_transposed/conv2d_transposed.h b/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d_transposed/conv2d_transposed.h deleted file mode 100644 index 34873570947..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d_transposed/conv2d_transposed.h +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#pragma once - -#include -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8ta_conv2d_transposed { - -constexpr int64_t kRefDimSizeLimit = 100; - -TestCase create_test_case_from_config( - const Conv2dConfig& config, - int32_t output_pad_h, - int32_t output_pad_w, - vkapi::ScalarType input_dtype, - utils::StorageType fp_storage_type, - utils::GPUMemoryLayout int8_memory_layout); - -void reference_impl(TestCase& test_case); - -int64_t quantized_conv2d_transposed_flop_calculator(const TestCase& test_case); - -} // namespace q8ta_conv2d_transposed -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d_transposed/test_sets/conv2d_transposed_sets.cpp b/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d_transposed/test_sets/conv2d_transposed_sets.cpp deleted file mode 100644 index 46bdef86bcb..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8ta/conv2d_transposed/test_sets/conv2d_transposed_sets.cpp +++ /dev/null @@ -1,196 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8ta_conv2d_transposed { - -namespace { - -// Each entry: {config, output_pad_h, output_pad_w} -struct TransposedConvTestConfig { - Conv2dConfig config; - int32_t output_pad_h; - int32_t output_pad_w; -}; - -std::vector generate_test_cases( - const std::vector& configs, - const std::vector& int8_memory_layouts, - bool require_int8_dot_product = true) { - std::vector test_cases; - if (require_int8_dot_product && - !vkcompute::api::context()->adapter_ptr()->supports_int8_dot_product()) { - return test_cases; - } - - for (auto tc : configs) { - auto& config = tc.config; - bool is_performance = config.channels.out > kRefDimSizeLimit || - config.channels.in > kRefDimSizeLimit || - config.input_size.h > kRefDimSizeLimit || - config.input_size.w > kRefDimSizeLimit; - - for (const utils::GPUMemoryLayout int8_memory_layout : - int8_memory_layouts) { - config.test_case_name = make_test_case_name( - config, is_performance, utils::kTexture3D, utils::kBuffer); - - test_cases.push_back(create_test_case_from_config( - config, - tc.output_pad_h, - tc.output_pad_w, - vkapi::kFloat, - utils::kTexture3D, - int8_memory_layout)); - } - } - - return test_cases; -} - -} // namespace - -REGISTER_TEST_CASE_SET("q8ta/conv2d_transposed", "correctness") { - const std::vector configs = { - // Basic transposed conv (stride=2, common in decoder networks) - {{OutInChannels(16, 32), - InputSize2D(8, 8), - KernelSize(3, 3), - Stride(2, 2), - Padding(1, 1), - Dilation(1, 1), - 1}, - 1, - 1}, - {{OutInChannels(32, 64), - InputSize2D(4, 4), - KernelSize(3, 3), - Stride(2, 2), - Padding(1, 1), - Dilation(1, 1), - 1}, - 1, - 1}, - // No output padding - {{OutInChannels(16, 32), - InputSize2D(8, 8), - KernelSize(4, 4), - Stride(2, 2), - Padding(1, 1), - Dilation(1, 1), - 1}, - 0, - 0}, - // Stride=1 (degenerate case) - {{OutInChannels(16, 16), - InputSize2D(8, 8), - KernelSize(3, 3), - Stride(1, 1), - Padding(1, 1), - Dilation(1, 1), - 1}, - 0, - 0}, - // Grouped transposed conv - {{OutInChannels(32, 64), - InputSize2D(8, 8), - KernelSize(3, 3), - Stride(2, 2), - Padding(1, 1), - Dilation(1, 1), - 2}, - 1, - 1}, - }; - const std::vector int8_memory_layouts = { - utils::kPackedInt8_4C1W, - utils::kPackedInt8_4W4C, - utils::kPackedInt8_4C, - }; - return { - generate_test_cases(configs, int8_memory_layouts), - reference_impl, - quantized_conv2d_transposed_flop_calculator}; -} - -REGISTER_TEST_CASE_SET("q8ta/conv2d_transposed", "debug") { - const std::vector configs = { - // Easy test case for debugging - {{OutInChannels(16, 32), - InputSize2D(8, 8), - KernelSize(3, 3), - Stride(2, 2), - Padding(1, 1), - Dilation(1, 1), - 1}, - /*output_pad_h=*/1, - /*output_pad_w=*/1}, - }; - const std::vector int8_memory_layouts = { - utils::kPackedInt8_4C1W, - utils::kPackedInt8_4W4C, - utils::kPackedInt8_4C, - }; - return { - generate_test_cases( - configs, int8_memory_layouts, /*require_int8_dot_product=*/false), - reference_impl, - quantized_conv2d_transposed_flop_calculator}; -} - -REGISTER_TEST_CASE_SET("q8ta/conv2d_transposed", "performance") { - const std::vector configs = { - // Larger spatial - {{OutInChannels(64, 128), - InputSize2D(16, 16), - KernelSize(4, 4), - Stride(2, 2), - Padding(1, 1), - Dilation(1, 1), - 1}, - 0, - 0}, - // Performance cases - {{OutInChannels(64, 128), - InputSize2D(32, 32), - KernelSize(3, 3), - Stride(2, 2), - Padding(1, 1), - Dilation(1, 1), - 1}, - 1, - 1}, - {{OutInChannels(128, 256), - InputSize2D(16, 16), - KernelSize(4, 4), - Stride(2, 2), - Padding(1, 1), - Dilation(1, 1), - 1}, - 0, - 0}, - }; - const std::vector int8_memory_layouts = { - utils::kPackedInt8_4C1W, - utils::kPackedInt8_4W4C, - utils::kPackedInt8_4C, - }; - return { - generate_test_cases(configs, int8_memory_layouts), - reference_impl, - quantized_conv2d_transposed_flop_calculator}; -} - -} // namespace q8ta_conv2d_transposed -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8ta/linear/linear.cpp b/backends/vulkan/test/ops/benchmark/cases/q8ta/linear/linear.cpp deleted file mode 100644 index 3629d293eb0..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8ta/linear/linear.cpp +++ /dev/null @@ -1,293 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include -#include -#include -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8ta_linear { - -TestCase create_test_case_from_config( - const LinearConfig& config, - vkapi::ScalarType input_dtype, - const std::string& impl_selector) { - TestCase test_case; - - bool is_perf = config.M >= kRefDimSizeLimit || config.K >= kRefDimSizeLimit || - config.N >= kRefDimSizeLimit; - std::string prefix = is_perf ? "PERF" : "ACCU"; - std::string dtype_str = dtype_short(input_dtype); - std::string shape_str = "[" + std::to_string(config.M) + "," + - std::to_string(config.K) + "]x[" + std::to_string(config.N) + "," + - std::to_string(config.K) + "]"; - std::string storage_str = repr_str(utils::kBuffer, utils::kWidthPacked); - std::string suffix; - if (!config.has_bias) { - suffix = "no_bias"; - } - if (!impl_selector.empty()) { - if (!suffix.empty()) { - suffix += " "; - } - suffix += "[" + impl_selector + "]"; - } - std::string test_name = make_test_label( - prefix, dtype_str, dtype_str, shape_str, storage_str, suffix); - test_case.set_name(test_name); - - test_case.set_operator_name("test_etvk.test_q8ta_linear.default"); - - std::vector input_size = {config.M, config.K}; - std::vector weight_size = {config.N, config.K}; - - // Input tensor (float) - [M, K] - ValueSpec input_tensor( - input_size, - input_dtype, - utils::kBuffer, - utils::kWidthPacked, - DataGenType::RANDOM); - - if (debugging()) { - print_valuespec_data(input_tensor, "input_tensor"); - } - - float input_scale_val = 0.008f; - ValueSpec input_scale(input_scale_val); - - int32_t input_zero_point_val = -2; - ValueSpec input_zero_point(input_zero_point_val); - - // Quantized weight tensor (int8) - [N, K] - ValueSpec quantized_weight( - weight_size, - vkapi::kChar, - utils::kBuffer, - utils::kWidthPacked, - DataGenType::RANDINT8); - quantized_weight.set_constant(true); - - if (debugging()) { - print_valuespec_data(quantized_weight, "weight_tensor"); - } - - // Weight quantization scales (float, per-channel) - ValueSpec weight_scales( - {config.N}, - input_dtype, - utils::kBuffer, - utils::kWidthPacked, - DataGenType::RANDOM_SCALES); - weight_scales.set_constant(true); - - ValueSpec weight_sums( - {config.N}, - vkapi::kInt, - utils::kBuffer, - utils::kWidthPacked, - DataGenType::ZEROS); - weight_sums.set_constant(true); - - // Compute weight_sums data based on quantized weights - compute_weight_sums(weight_sums, quantized_weight, config.N, config.K); - - // Output quantization parameters - float output_scale_val = 0.05314f; - ValueSpec output_scale(output_scale_val); - - int32_t output_zero_point_val = -1; - ValueSpec output_zero_point(output_zero_point_val); - - // Bias (optional, float) - [N] - ValueSpec bias( - {config.N}, - input_dtype, - utils::kBuffer, - utils::kWidthPacked, - DataGenType::RANDOM); - bias.set_constant(true); - if (!config.has_bias) { - bias.set_none(true); - } - - // Output tensor (float) - [M, N] - ValueSpec output( - {config.M, config.N}, - input_dtype, - utils::kBuffer, - utils::kWidthPacked, - DataGenType::ZEROS); - - // Add all specs to test case - test_case.add_input_spec(input_tensor); - test_case.add_input_spec(input_scale); - test_case.add_input_spec(input_zero_point); - test_case.add_input_spec(quantized_weight); - test_case.add_input_spec(weight_sums); - test_case.add_input_spec(weight_scales); - test_case.add_input_spec(output_scale); - test_case.add_input_spec(output_zero_point); - test_case.add_input_spec(bias); - - // Activation (none = no activation) - ValueSpec activation = ValueSpec::make_string("none"); - test_case.add_input_spec(activation); - - // Add impl_selector string - ValueSpec impl_selector_spec = ValueSpec::make_string(impl_selector); - test_case.add_input_spec(impl_selector_spec); - test_case.add_output_spec(output); - - test_case.set_abs_tolerance(output_scale_val + 1e-4f); - - // Filter out quantize/dequantize shaders from timing measurements - test_case.set_shader_filter({ - "nchw_to", - "to_nchw", - "q8ta_quantize", - "q8ta_dequantize", - }); - - return test_case; -} - -int64_t q8ta_linear_flop_calculator(const TestCase& test_case) { - const auto& input_sizes = test_case.inputs()[0].get_tensor_sizes(); - const auto& weight_sizes = test_case.inputs()[3].get_tensor_sizes(); - - int64_t batch_size = input_sizes[0]; - int64_t in_features = input_sizes[1]; - int64_t out_features = weight_sizes[0]; - - int64_t output_elements = batch_size * out_features; - int64_t ops_per_output = in_features; - - int64_t flop = output_elements * ops_per_output; - - return flop; -} - -namespace { - -// Reference implementation for q8ta_linear (activation+weight+output quantized) -void q8ta_linear_reference_impl(TestCase& test_case) { - int32_t idx = 0; - const ValueSpec& input_spec = test_case.inputs()[idx++]; - const ValueSpec& input_scale_spec = test_case.inputs()[idx++]; - const ValueSpec& input_zeros_spec = test_case.inputs()[idx++]; - const ValueSpec& weight_spec = test_case.inputs()[idx++]; - const ValueSpec& weight_sums_spec = test_case.inputs()[idx++]; - (void)weight_sums_spec; - const ValueSpec& weight_scales_spec = test_case.inputs()[idx++]; - const ValueSpec& output_scale_spec = test_case.inputs()[idx++]; - const ValueSpec& output_zeros_spec = test_case.inputs()[idx++]; - const ValueSpec& bias_spec = test_case.inputs()[idx++]; - const ValueSpec& activation_spec = test_case.inputs()[idx++]; - (void)activation_spec; - const ValueSpec& impl_selector_spec = test_case.inputs()[idx++]; - (void)impl_selector_spec; - - ValueSpec& output_spec = test_case.outputs()[0]; - - auto input_sizes = input_spec.get_tensor_sizes(); - auto weight_sizes = weight_spec.get_tensor_sizes(); - - int64_t batch_size = input_sizes[0]; - int64_t in_features = input_sizes[1]; - int64_t out_features = weight_sizes[0]; - - if (batch_size > kRefDimSizeLimit || in_features > kRefDimSizeLimit || - out_features > kRefDimSizeLimit) { - throw std::invalid_argument( - "One or more dimensions exceed the allowed limit for reference implementation."); - } - - if (input_spec.dtype != vkapi::kFloat) { - throw std::invalid_argument("Unsupported dtype"); - } - - auto& input_data = input_spec.get_float_data(); - const float input_scale = input_scale_spec.get_float_value(); - const int32_t input_zero_point = input_zeros_spec.get_int_value(); - - auto& weight_data = weight_spec.get_int8_data(); - auto& weight_scales_data = weight_scales_spec.get_float_data(); - auto& bias_data = bias_spec.get_float_data(); - - const float output_scale = output_scale_spec.get_float_value(); - const int32_t output_zero_point = output_zeros_spec.get_int_value(); - - int64_t num_output_elements = batch_size * out_features; - - auto& ref_data = output_spec.get_ref_float_data(); - ref_data.resize(num_output_elements); - - for (int64_t b = 0; b < batch_size; ++b) { - for (int64_t out_f = 0; out_f < out_features; ++out_f) { - int32_t int_sum = 0; - int32_t weight_sum = 0; - - for (int64_t in_f = 0; in_f < in_features; ++in_f) { - int64_t input_idx = b * in_features + in_f; - - float quant_input_f = - std::round(input_data[input_idx] / input_scale) + input_zero_point; - quant_input_f = std::min(std::max(quant_input_f, -128.0f), 127.0f); - int8_t quantized_input = static_cast(quant_input_f); - - int64_t weight_idx = out_f * in_features + in_f; - int8_t quantized_weight = weight_data[weight_idx]; - - int_sum += static_cast(quantized_input) * - static_cast(quantized_weight); - - weight_sum += static_cast(quantized_weight); - } - - int32_t zero_point_correction = input_zero_point * weight_sum; - int32_t accum_adjusted = int_sum - zero_point_correction; - - float float_result = - accum_adjusted * input_scale * weight_scales_data[out_f]; - - if (!bias_spec.is_none()) { - float_result += bias_data[out_f]; - } - - // Quantize the output to int8 - float quant_output_f = - std::round(float_result / output_scale) + output_zero_point; - quant_output_f = std::min(std::max(quant_output_f, -128.0f), 127.0f); - int8_t quantized_output = static_cast(quant_output_f); - - // Dequantize back to float (this is what the test wrapper does) - float dequant_output = - (static_cast(quantized_output) - output_zero_point) * - output_scale; - - int64_t output_idx = b * out_features + out_f; - ref_data[output_idx] = dequant_output; - } - } -} - -} // namespace - -void reference_impl(TestCase& test_case) { - q8ta_linear_reference_impl(test_case); -} - -} // namespace q8ta_linear -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8ta/linear/linear.h b/backends/vulkan/test/ops/benchmark/cases/q8ta/linear/linear.h deleted file mode 100644 index 0f27844ff43..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8ta/linear/linear.h +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#pragma once - -#include -#include - -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8ta_linear { - -constexpr int64_t kRefDimSizeLimit = 300; - -struct LinearConfig { - int64_t M; - int64_t K; - int64_t N; - bool has_bias = true; - std::string test_case_name = "placeholder"; -}; - -TestCase create_test_case_from_config( - const LinearConfig& config, - vkapi::ScalarType input_dtype, - const std::string& impl_selector = ""); - -void reference_impl(TestCase& test_case); - -int64_t q8ta_linear_flop_calculator(const TestCase& test_case); - -} // namespace q8ta_linear -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8ta/linear/test_sets/linear_sets.cpp b/backends/vulkan/test/ops/benchmark/cases/q8ta/linear/test_sets/linear_sets.cpp deleted file mode 100644 index 1483728fc47..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8ta/linear/test_sets/linear_sets.cpp +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8ta_linear { - -namespace { - -std::vector generate_test_cases( - const std::vector& configs) { - std::vector test_cases; - - for (auto config : configs) { - bool is_performance = config.M >= kRefDimSizeLimit || - config.K >= kRefDimSizeLimit || config.N >= kRefDimSizeLimit; - - std::string prefix = is_performance ? "performance_" : "correctness_"; - std::string generated_test_case_name = prefix + std::to_string(config.M) + - "_" + std::to_string(config.K) + "_" + std::to_string(config.N); - if (!config.has_bias) { - generated_test_case_name += "_no_bias"; - } - - config.test_case_name = generated_test_case_name; - - // Default (tiled) variant - test_cases.push_back(create_test_case_from_config(config, vkapi::kFloat)); - - // For batch size 1, also test the gemv variant - if (config.M == 1) { - test_cases.push_back( - create_test_case_from_config(config, vkapi::kFloat, "gemv")); - } - } - - return test_cases; -} - -} // namespace - -REGISTER_TEST_CASE_SET("q8ta/linear", "correctness") { - const std::vector configs = { - // Batch size 1 cases (test both tiled and gemv) - {1, 64, 32}, - {1, 128, 64}, - {1, 256, 128}, - {1, 128, 64, false}, - // Multi-batch cases - {4, 64, 32}, - {4, 128, 64}, - {4, 256, 128}, - {32, 64, 32}, - {32, 128, 64}, - {32, 256, 128}, - // No bias tests - {32, 128, 64, false}, - {32, 256, 128, false}, - }; - - return { - generate_test_cases(configs), - reference_impl, - q8ta_linear_flop_calculator}; -} - -REGISTER_TEST_CASE_SET("q8ta/linear", "performance") { - const std::vector configs = { - // Performance cases - {1, 512, 512}, - {1, 2048, 2048}, - {1, 512, 9059}, - {256, 2048, 2048}, - {512, 2048, 2048}, - {1024, 2048, 2048}, - }; - - return { - generate_test_cases(configs), - reference_impl, - q8ta_linear_flop_calculator}; -} - -} // namespace q8ta_linear -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8ta/pixel_shuffle/pixel_shuffle.cpp b/backends/vulkan/test/ops/benchmark/cases/q8ta/pixel_shuffle/pixel_shuffle.cpp deleted file mode 100644 index e97b5f564c8..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8ta/pixel_shuffle/pixel_shuffle.cpp +++ /dev/null @@ -1,193 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include -#include -#include -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8ta_pixel_shuffle { - -TestCase create_test_case_from_config(const PixelShuffleConfig& config) { - TestCase test_case; - - std::string prefix = config.test_case_name; // "ACCU" or "PERF" - std::string shape_str = shape_bracket(config.in_shape) + - " r=" + std::to_string(config.upscale_factor); - // Storage section captures the layout transition between int8 layouts. - std::string storage_str = - "Buf(" + config.in_layout + ")->Buf(" + config.out_layout + ")"; - std::string suffix = config.same_qparams ? "[same_qp]" : "[diff_qp]"; - std::string test_name = make_test_label( - prefix, - dtype_short(vkapi::kChar), - dtype_short(vkapi::kChar), - shape_str, - storage_str, - suffix); - test_case.set_name(test_name); - - std::string operator_name = "test_etvk." + config.op_name + ".default"; - test_case.set_operator_name(operator_name); - - // FP input: shape [N, C_in, H, W]. With matching qparams, integer inputs and - // a unit scale make quantization exact, so the output must match the - // reference bit for bit. - ValueSpec input_tensor( - config.in_shape, - vkapi::kFloat, - utils::kBuffer, - utils::kWidthPacked, - config.same_qparams ? DataGenType::RANDINT8 : DataGenType::RANDOM); - - float input_scale_val = config.same_qparams ? 1.0f : 0.007112f; - ValueSpec input_scale(input_scale_val); - int32_t input_zp_val = 0; - ValueSpec input_zp(input_zp_val); - - float output_scale_val = config.same_qparams ? input_scale_val : 0.013f; - ValueSpec output_scale(output_scale_val); - int32_t output_zp_val = config.same_qparams ? input_zp_val : 5; - ValueSpec output_zp(output_zp_val); - - ValueSpec upscale_factor(static_cast(config.upscale_factor)); - - ValueSpec in_layout_spec = ValueSpec::make_string(config.in_layout); - ValueSpec out_layout_spec = ValueSpec::make_string(config.out_layout); - - // Output shape - std::vector out_shape = { - config.in_shape[0], - config.in_shape[1] / (config.upscale_factor * config.upscale_factor), - config.in_shape[2] * config.upscale_factor, - config.in_shape[3] * config.upscale_factor}; - ValueSpec output_tensor( - out_shape, - vkapi::kFloat, - utils::kBuffer, - utils::kWidthPacked, - DataGenType::ZEROS); - - test_case.add_input_spec(input_tensor); - test_case.add_input_spec(input_scale); - test_case.add_input_spec(input_zp); - test_case.add_input_spec(output_scale); - test_case.add_input_spec(output_zp); - test_case.add_input_spec(upscale_factor); - test_case.add_input_spec(in_layout_spec); - test_case.add_input_spec(out_layout_spec); - test_case.add_output_spec(output_tensor); - - if (config.same_qparams) { - test_case.set_abs_tolerance(0.0f); - test_case.set_rel_tolerance(0.0f); - } else { - // Requantization rounding may differ by ~1 quant step in the bigger of the - // two scales. - float tol = std::max(input_scale_val, output_scale_val) + 1e-4f; - test_case.set_abs_tolerance(tol); - } - - // Filter shaders that are just measurement overhead (staging copies and the - // surrounding quantize/dequantize that wrap the operation under test). - test_case.set_shader_filter({ - "nchw_to", - "to_nchw", - "q8ta_quantize", - "q8ta_dequantize", - }); - - return test_case; -} - -// Reference: quantize input, do PyTorch-equivalent pixel shuffle, requantize, -// then dequantize for comparison. -void q8ta_pixel_shuffle_reference_impl(TestCase& test_case) { - int32_t idx = 0; - const ValueSpec& input_spec = test_case.inputs()[idx++]; - const ValueSpec& input_scale_spec = test_case.inputs()[idx++]; - const ValueSpec& input_zp_spec = test_case.inputs()[idx++]; - const ValueSpec& output_scale_spec = test_case.inputs()[idx++]; - const ValueSpec& output_zp_spec = test_case.inputs()[idx++]; - const ValueSpec& upscale_factor_spec = test_case.inputs()[idx++]; - - ValueSpec& output_spec = test_case.outputs()[0]; - - const auto in_sizes = input_spec.get_tensor_sizes(); - for (auto d : in_sizes) { - if (d > kRefDimSizeLimit) { - throw std::invalid_argument("Dim exceeds reference compute limit"); - } - } - - const int64_t N = in_sizes[0]; - const int64_t C_in = in_sizes[1]; - const int64_t H_in = in_sizes[2]; - const int64_t W_in = in_sizes[3]; - const int32_t r = upscale_factor_spec.get_int_value(); - const int64_t C_out = C_in / (r * r); - const int64_t H_out = H_in * r; - const int64_t W_out = W_in * r; - - const float input_scale = input_scale_spec.get_float_value(); - const int32_t input_zp = input_zp_spec.get_int_value(); - const float output_scale = output_scale_spec.get_float_value(); - const int32_t output_zp = output_zp_spec.get_int_value(); - const int32_t qmin = -128; - const int32_t qmax = 127; - - const auto& input_data = input_spec.get_float_data(); - auto& ref = output_spec.get_ref_float_data(); - ref.resize(N * C_out * H_out * W_out); - - for (int64_t n = 0; n < N; ++n) { - for (int64_t c_out = 0; c_out < C_out; ++c_out) { - for (int64_t oh = 0; oh < H_out; ++oh) { - for (int64_t ow = 0; ow < W_out; ++ow) { - const int64_t c_in = c_out * r * r + (oh % r) * r + (ow % r); - const int64_t ih = oh / r; - const int64_t iw = ow / r; - const int64_t in_idx = ((n * C_in + c_in) * H_in + ih) * W_in + iw; - const float fp_in = input_data[in_idx]; - - // Quantize with input qparams - float qf = std::round(fp_in / input_scale) + input_zp; - qf = std::max(qf, static_cast(qmin)); - qf = std::min(qf, static_cast(qmax)); - int32_t q_in = static_cast(qf); - - // Dequantize back to fp using the input qparams (this models the - // dequantize node in the chain) - float dq = (q_in - input_zp) * input_scale; - - // Requantize to int8 with output qparams - float rqf = std::round(dq / output_scale) + output_zp; - rqf = std::max(rqf, static_cast(qmin)); - rqf = std::min(rqf, static_cast(qmax)); - int32_t q_out = static_cast(rqf); - - // Final dequantize to fp for comparison - float fp_out = (q_out - output_zp) * output_scale; - - const int64_t out_idx = - ((n * C_out + c_out) * H_out + oh) * W_out + ow; - ref[out_idx] = fp_out; - } - } - } - } -} - -} // namespace q8ta_pixel_shuffle -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8ta/pixel_shuffle/pixel_shuffle.h b/backends/vulkan/test/ops/benchmark/cases/q8ta/pixel_shuffle/pixel_shuffle.h deleted file mode 100644 index c85491fe62a..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8ta/pixel_shuffle/pixel_shuffle.h +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#pragma once - -#include -#include - -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8ta_pixel_shuffle { - -constexpr int64_t kRefDimSizeLimit = 512; - -// Test op signature in TestQ8taPixelShuffle.cpp: -// test_q8ta_pixel_shuffle(fp_in, in_scale, in_zp, out_scale, out_zp, -// upscale_factor, in_layout, out_layout) -> fp_out -// Implementation: fused fast-path kernel. The in_layout / out_layout strings -// select the channels-packed int8x4 layout used for the temporary quantized -// tensors. Supported values: "4W4C", "4C", "4C1W". -// (PACKED_INT8_CONV2D is a Python/serialization-level alias that the runtime -// resolves to kPackedInt8_4C or kPackedInt8_4C1W, so it is not exercised -// separately here.) - -struct PixelShuffleConfig { - std::vector in_shape; // [N, C*r*r, H, W] - int upscale_factor; - bool same_qparams; // if true, in_scale == out_scale and in_zp == out_zp - std::string in_layout = "4W4C"; - std::string out_layout = "4W4C"; - std::string test_case_name = "ACCU"; - std::string op_name = "test_q8ta_pixel_shuffle"; -}; - -TestCase create_test_case_from_config(const PixelShuffleConfig& config); - -void q8ta_pixel_shuffle_reference_impl(TestCase& test_case); - -} // namespace q8ta_pixel_shuffle -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8ta/pixel_shuffle/test_sets/pixel_shuffle_sets.cpp b/backends/vulkan/test/ops/benchmark/cases/q8ta/pixel_shuffle/test_sets/pixel_shuffle_sets.cpp deleted file mode 100644 index 4f4cea07ffd..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8ta/pixel_shuffle/test_sets/pixel_shuffle_sets.cpp +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8ta_pixel_shuffle { - -namespace { - -// Channels-packed int8 layouts. CONV2D is a Python-level alias that resolves to -// 4C or 4C1W at the C++ runtime, so it is not listed separately. -const std::vector kLayouts = {"4W4C", "4C", "4C1W"}; - -std::vector generate_test_cases( - const std::vector>& in_shapes, - int upscale_factor, - const std::vector& same_qparams, - const std::string& test_case_name) { - std::vector test_cases; - - for (const auto& shape : in_shapes) { - for (bool same_qp : same_qparams) { - for (const std::string& in_layout : kLayouts) { - for (const std::string& out_layout : kLayouts) { - PixelShuffleConfig cfg; - cfg.in_shape = shape; - cfg.upscale_factor = upscale_factor; - cfg.same_qparams = same_qp; - cfg.in_layout = in_layout; - cfg.out_layout = out_layout; - cfg.test_case_name = test_case_name; - - test_cases.push_back(create_test_case_from_config(cfg)); - } - } - } - } - - return test_cases; -} - -} // namespace - -REGISTER_TEST_CASE_SET("q8ta/pixel_shuffle", "correctness") { - // Small shapes, all use r=2 (the only factor needed by the model). - // Shape format is the *input* shape [N, C_in, H, W] where C_in = C_out * r*r. - const std::vector> in_shapes = { - // Small even W to be a multiple of 4 after upscaling. - {1, 16, 4, 4}, // out: [1, 4, 8, 8] - {1, 24, 8, 4}, // out: [1, 6, 16, 8] - {1, 32, 12, 8}, // out: [1, 8, 24, 16] - {1, 96, 16, 9}, // out: [1, 24, 32, 18] - first model shape - {1, 4, 5, 7}, // out: [1, 1, 10, 14] - 3 padding channel lanes - {2, 32, 6, 5}, // out: [2, 8, 12, 10] - batch > 1 - }; - const std::vector same_qparams = {true, false}; - return { - generate_test_cases( - in_shapes, /*upscale_factor=*/2, same_qparams, "ACCU"), - q8ta_pixel_shuffle_reference_impl}; -} - -REGISTER_TEST_CASE_SET("q8ta/pixel_shuffle", "performance") { - // Model perf shapes (output shapes from the RefineNet decoder; we compute - // the input shape as [N, C_out * r*r, H_out / r, W_out / r]). - // Output shapes: [1, 24, 32, 18], [1, 24, 64, 36], [1, 24, 128, 72], - // [1, 24, 256, 144]. For r=2, in shapes = [1, 96, 16, 9], etc. - const std::vector> in_shapes = { - {1, 96, 16, 9}, - {1, 96, 32, 18}, - {1, 96, 64, 36}, - {1, 96, 128, 72}, - // Upsampling decoder with 64 output channels. - {1, 256, 16, 16}, - {1, 256, 32, 32}, - {1, 256, 64, 64}, - {1, 256, 128, 128}, - {1, 256, 256, 256}, - }; - const std::vector same_qparams = {true}; // residual-style: scales match - return { - generate_test_cases( - in_shapes, /*upscale_factor=*/2, same_qparams, "PERF"), - q8ta_pixel_shuffle_reference_impl}; -} - -} // namespace q8ta_pixel_shuffle -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8ta/qdq/qdq.cpp b/backends/vulkan/test/ops/benchmark/cases/q8ta/qdq/qdq.cpp deleted file mode 100644 index b6ad788b9db..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8ta/qdq/qdq.cpp +++ /dev/null @@ -1,164 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include -#include -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8ta_qdq { - -// Utility function to create a test case from a QDQ8BitConfig -TestCase create_test_case_from_config( - const QDQ8BitConfig& config, - utils::StorageType storage_type, - vkapi::ScalarType input_dtype, - utils::GPUMemoryLayout fp_memory_layout, - utils::GPUMemoryLayout quantized_memory_layout, - const std::string& impl_selector) { - TestCase test_case; - - // Create a descriptive name for the test case - // QDQ: fp input -> int8 staging -> fp output (label as f32->f32) - std::string prefix = config.test_case_name; // "ACCU" or "PERF" - std::string storage_str = repr_str(storage_type, fp_memory_layout) + "->" + - repr_str(utils::kBuffer, quantized_memory_layout); - std::string suffix = impl_selector.empty() ? "" : "[" + impl_selector + "]"; - std::string test_name = make_test_label( - prefix, - dtype_short(input_dtype), - dtype_short(input_dtype), - shape_bracket(config.shape), - storage_str, - suffix); - test_case.set_name(test_name); - - // Set the operator name for the test case - std::string operator_name = "test_etvk." + config.op_name + ".default"; - test_case.set_operator_name(operator_name); - - // Input tensor (float) - any dimensionality - ValueSpec input_tensor( - config.shape, - input_dtype, - storage_type, - fp_memory_layout, - DataGenType::RANDOM); - - float scale_val = 0.007112; - ValueSpec scale(scale_val); - - // Generate random zero point within quantization range - int32_t zero_point_val = 0; - ValueSpec zero_point(zero_point_val); - - // GPUMemoryLayout as integer (will be cast in the operator) - int32_t layout_int = static_cast(quantized_memory_layout); - ValueSpec layout_spec(layout_int); - - // impl_selector string - ValueSpec impl_selector_spec = ValueSpec::make_string(impl_selector); - - // Output tensor (float) - same shape as input - ValueSpec output_tensor( - config.shape, - input_dtype, - storage_type, - fp_memory_layout, - DataGenType::ZEROS); - - // Add all specs to test case - test_case.add_input_spec(input_tensor); - test_case.add_input_spec(scale); - test_case.add_input_spec(zero_point); - test_case.add_input_spec(layout_spec); - test_case.add_input_spec(impl_selector_spec); - test_case.add_output_spec(output_tensor); - - test_case.set_abs_tolerance(scale_val + 1e-4); - - // Use layout-only filter for this test since quantize/dequantize ARE the - // operations being tested, not overhead - test_case.set_shader_filter(kLayoutOnlyShaderFilter); - - return test_case; -} - -// Reference implementation for q_dq_8bit operation -void q_dq_8bit_reference_impl(TestCase& test_case) { - int32_t idx = 0; - const ValueSpec& input_spec = test_case.inputs()[idx++]; - const ValueSpec& scale_spec = test_case.inputs()[idx++]; - const ValueSpec& zero_point_spec = test_case.inputs()[idx++]; - const ValueSpec& layout_spec = test_case.inputs()[idx++]; - (void)layout_spec; // Not used in reference implementation - - // Extract output specification - ValueSpec& output_spec = test_case.outputs()[0]; - - // Get tensor dimensions (arbitrary dimensionality) - auto input_sizes = input_spec.get_tensor_sizes(); - - // Calculate total number of elements - int64_t num_elements = 1; - for (const auto& dim : input_sizes) { - num_elements *= dim; - } - - // Skip for large tensors since computation time will be extremely slow - for (const auto& dim : input_sizes) { - if (dim > kRefDimSizeLimit) { - throw std::invalid_argument( - "One or more dimensions exceed the allowed limit for reference implementation."); - } - } - - if (input_spec.dtype != vkapi::kFloat) { - throw std::invalid_argument("Unsupported dtype"); - } - - // Get raw data pointers - auto& input_data = input_spec.get_float_data(); - - // Extract the randomized scale and zero point values - float scale = scale_spec.get_float_value(); - int32_t zero_point = zero_point_spec.get_int_value(); - int32_t quant_min = -128; - int32_t quant_max = 127; - - // Prepare output data - auto& ref_data = output_spec.get_ref_float_data(); - ref_data.resize(num_elements); - - // Perform quantize-dequantize operation on each element - for (int64_t i = 0; i < num_elements; ++i) { - float input_val = input_data[i]; - - // Quantize: quantized = round(input / scale + zero_point) - float quantized_float = std::round(input_val / scale) + zero_point; - - // Clamp to quantization range - quantized_float = std::max(quantized_float, static_cast(quant_min)); - quantized_float = std::min(quantized_float, static_cast(quant_max)); - - int32_t quantized_int = static_cast(quantized_float); - - // Dequantize: output = (quantized - zero_point) * scale - float dequantized = (quantized_int - zero_point) * scale; - - ref_data[i] = dequantized; - } -} - -} // namespace q8ta_qdq -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8ta/qdq/qdq.h b/backends/vulkan/test/ops/benchmark/cases/q8ta/qdq/qdq.h deleted file mode 100644 index 7b63610edff..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8ta/qdq/qdq.h +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#pragma once - -#include -#include - -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8ta_qdq { - -constexpr int64_t kRefDimSizeLimit = 512; - -// Configuration struct for tensor quantize-dequantize testing -struct QDQ8BitConfig { - std::vector shape; // Tensor shape (can be any dimensionality) - std::string test_case_name = "placeholder"; - std::string op_name = "q_dq_8bit_per_tensor"; -}; - -TestCase create_test_case_from_config( - const QDQ8BitConfig& config, - utils::StorageType storage_type, - vkapi::ScalarType input_dtype, - utils::GPUMemoryLayout fp_memory_layout, - utils::GPUMemoryLayout quantized_memory_layout, - const std::string& impl_selector = ""); - -void q_dq_8bit_reference_impl(TestCase& test_case); - -} // namespace q8ta_qdq -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8ta/qdq/test_sets/qdq_sets.cpp b/backends/vulkan/test/ops/benchmark/cases/q8ta/qdq/test_sets/qdq_sets.cpp deleted file mode 100644 index 18aa2cce638..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8ta/qdq/test_sets/qdq_sets.cpp +++ /dev/null @@ -1,202 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8ta_qdq { - -namespace { - -std::vector generate_test_cases( - const std::vector>& shapes, - const std::vector& fp_layouts, - const std::vector& quant_layouts, - const std::vector& storage_types, - const std::vector& legacy_4w4c_fp_layouts) { - std::vector test_cases; - - // Generate all combinations - for (const auto& shape : shapes) { - // Generate test case name prefix from shape dimensions - std::string prefix = "ACCU"; - for (const auto& dim : shape) { - if (dim > kRefDimSizeLimit) { - prefix = "PERF"; - break; - } - } - - const bool is_highdim = shape.size() > 4; - - for (const auto& fp_layout : fp_layouts) { - const bool test_legacy_4w4c = - std::find( - legacy_4w4c_fp_layouts.begin(), - legacy_4w4c_fp_layouts.end(), - fp_layout) != legacy_4w4c_fp_layouts.end(); - for (const auto& quant_layout : quant_layouts) { - for (const auto& storage_type : storage_types) { - // Textures are limited to 4D - if (is_highdim && storage_type != utils::kBuffer) { - continue; - } - QDQ8BitConfig config; - config.shape = shape; - config.test_case_name = prefix; - - test_cases.push_back(create_test_case_from_config( - config, storage_type, vkapi::kFloat, fp_layout, quant_layout)); - // For 4W4C layout, also test with legacy implementation - // (legacy path doesn't support high-dim tensors) - if (!is_highdim && test_legacy_4w4c && - quant_layout == utils::kPackedInt8_4W4C) { - test_cases.push_back(create_test_case_from_config( - config, - storage_type, - vkapi::kFloat, - fp_layout, - quant_layout, - /*impl_selector=*/"legacy_4w4c")); - } - } - } - } - } - - return test_cases; -} - -} // namespace - -REGISTER_TEST_CASE_SET("q8ta/qdq", "correctness") { - // Shapes to test (no layout specified - will be combined with all - // layouts) - const std::vector> shapes = { - // 1D tensors - {144}, - {90}, - - // 2D tensors (exercises block config with ndim < 4) - {1, 144}, - {1, 90}, - {1, 4}, - {3, 32}, - - // 3D tensors - {1, 16, 32}, - {1, 3, 64}, - - // Small test cases for correctness - {1, 3, 16, 16}, - {1, 8, 32, 32}, - {1, 16, 24, 24}, - {1, 32, 12, 12}, - {1, 1, 64, 64}, - {1, 3, 64, 64}, - {1, 4, 16, 16}, - - // Different tensor sizes - {1, 8, 20, 20}, - {1, 16, 14, 14}, - {1, 8, 28, 28}, - - // Odd tensor sizes - {1, 3, 15, 15}, - {1, 13, 31, 31}, - {1, 17, 23, 23}, - - // Performance test cases (larger tensors) - {1, 64, 128, 128}, - {1, 32, 64, 64}, - {1, 128, 56, 56}, - - // 5D tensors (high-dim batch support) - {2, 1, 3, 16, 16}, - {1, 2, 8, 8, 8}, - - // 6D tensors (high-dim batch support) - {2, 1, 1, 4, 8, 16}, - {1, 1, 2, 3, 8, 8}, - }; - // FP memory layouts to test - const std::vector fp_layouts = { - utils::kWidthPacked, - utils::kChannelsPacked, - }; - // Quantized memory layouts to test - const std::vector quant_layouts = { - utils::kPackedInt8_4W, - utils::kPackedInt8_4C, - utils::kPackedInt8_4W4C, - utils::kPackedInt8_4H4W, - utils::kPackedInt8_4C1W, - }; - // Test with buffer storage only - the unified block-based shaders only - // support buffer-backed floating-point tensors. Texture storage is tested - // separately by qdq8ta_conv2d_activations which uses the layout-specific - // shaders. - const std::vector storage_types = { - utils::kBuffer, utils::kTexture3D}; - const std::vector legacy_4w4c_fp_layouts = { - utils::kChannelsPacked}; - return { - generate_test_cases( - shapes, - fp_layouts, - quant_layouts, - storage_types, - legacy_4w4c_fp_layouts), - q_dq_8bit_reference_impl}; -} - -// Easy test cases for q_dq_8bit operation (for debugging) -REGISTER_TEST_CASE_SET("q8ta/qdq", "debug") { - const std::vector> shapes = { - {1, 16, 16, 16}, // 4D: [N, C, H, W] - {1, 144}, // 2D: exercises block config with ndim < 4 - {1, 90}, // 2D: matches skin_seg model's keypoint/bbox tensor sizes - {2, 1, 3, 16, 16}, // 5D: exercises high-dim batch support - {2, 1, 1, 4, 8, 16}, // 6D: exercises high-dim batch support - }; - // FP memory layouts to test - const std::vector fp_layouts = { - utils::kWidthPacked, - utils::kChannelsPacked, - }; - // Quantized memory layouts to test - const std::vector quant_layouts = { - utils::kPackedInt8_4W, - utils::kPackedInt8_4C, - utils::kPackedInt8_4W4C, - utils::kPackedInt8_4H4W, - utils::kPackedInt8_4C1W, - }; - const std::vector storage_types = {utils::kBuffer}; - const std::vector legacy_4w4c_fp_layouts = { - utils::kWidthPacked, - utils::kChannelsPacked, - }; - return { - generate_test_cases( - shapes, - fp_layouts, - quant_layouts, - storage_types, - legacy_4w4c_fp_layouts), - q_dq_8bit_reference_impl}; -} - -} // namespace q8ta_qdq -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8ta/unary/test_sets/unary_sets.cpp b/backends/vulkan/test/ops/benchmark/cases/q8ta/unary/test_sets/unary_sets.cpp deleted file mode 100644 index 2d7340ee916..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8ta/unary/test_sets/unary_sets.cpp +++ /dev/null @@ -1,120 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8ta_unary { - -namespace { - -std::vector generate_test_cases( - const std::vector>& shapes, - const std::vector& fp_layouts, - const std::vector& quant_layouts, - const std::vector& storage_types) { - std::vector test_cases; - - for (const auto& shape : shapes) { - std::string prefix = "ACCU"; - for (const auto& dim : shape) { - if (dim > kRefDimSizeLimit) { - prefix = "PERF"; - break; - } - } - - for (const auto& fp_layout : fp_layouts) { - for (const auto& quant_layout : quant_layouts) { - for (const auto& storage_type : storage_types) { - Q8taUnaryConfig config; - config.shape = shape; - config.test_case_name = prefix; - - test_cases.push_back(create_test_case_from_config( - config, storage_type, vkapi::kFloat, fp_layout, quant_layout)); - } - } - } - } - - return test_cases; -} - -} // namespace - -REGISTER_TEST_CASE_SET("q8ta/unary", "correctness") { - const std::vector> shapes = { - {1, 3, 16, 16}, - {1, 8, 32, 32}, - {1, 16, 24, 24}, - {1, 32, 12, 12}, - {1, 1, 64, 64}, - {1, 3, 64, 64}, - {1, 4, 16, 16}, - - {1, 8, 20, 20}, - {1, 16, 14, 14}, - {1, 8, 28, 28}, - - // Odd tensor sizes - {1, 3, 15, 15}, - {1, 13, 31, 31}, - {1, 17, 23, 23}, - - // Larger tensors - {1, 64, 128, 128}, - {1, 32, 64, 64}, - {1, 128, 56, 56}, - {1, 128, 128, 128}, - }; - const std::vector fp_layouts = { - utils::kWidthPacked, - utils::kChannelsPacked, - }; - const std::vector quant_layouts = { - utils::kPackedInt8_4W, - utils::kPackedInt8_4C, - utils::kPackedInt8_4W4C, - utils::kPackedInt8_4H4W, - utils::kPackedInt8_4C1W, - }; - const std::vector storage_types = {utils::kBuffer}; - return { - generate_test_cases(shapes, fp_layouts, quant_layouts, storage_types), - q8ta_unary_reference_impl}; -} - -REGISTER_TEST_CASE_SET("q8ta/unary", "debug") { - const std::vector> shapes = { - {1, 16, 16, 16}, - }; - const std::vector fp_layouts = { - utils::kWidthPacked, - utils::kChannelsPacked, - }; - const std::vector quant_layouts = { - utils::kPackedInt8_4W, - utils::kPackedInt8_4C, - utils::kPackedInt8_4W4C, - utils::kPackedInt8_4H4W, - utils::kPackedInt8_4C1W, - }; - const std::vector storage_types = {utils::kBuffer}; - return { - generate_test_cases(shapes, fp_layouts, quant_layouts, storage_types), - q8ta_unary_reference_impl}; -} - -} // namespace q8ta_unary -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8ta/unary/unary.cpp b/backends/vulkan/test/ops/benchmark/cases/q8ta/unary/unary.cpp deleted file mode 100644 index d11d5c41a79..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8ta/unary/unary.cpp +++ /dev/null @@ -1,168 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include -#include -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8ta_unary { - -TestCase create_test_case_from_config( - const Q8taUnaryConfig& config, - utils::StorageType storage_type, - vkapi::ScalarType input_dtype, - utils::GPUMemoryLayout fp_memory_layout, - utils::GPUMemoryLayout quant_layout) { - TestCase test_case; - - std::string prefix = config.test_case_name; // "ACCU" or "PERF" - std::string storage_str = repr_str(storage_type, fp_memory_layout) + "->" + - repr_str(utils::kBuffer, quant_layout); - std::string test_name = make_test_label( - prefix, - dtype_short(input_dtype), - dtype_short(input_dtype), - shape_bracket(config.shape), - storage_str); - test_case.set_name(test_name); - - std::string operator_name = "test_etvk." + config.op_name + ".default"; - test_case.set_operator_name(operator_name); - - // Input tensor (float) - ValueSpec input_tensor( - config.shape, - input_dtype, - storage_type, - fp_memory_layout, - DataGenType::RANDOM); - - float scale_val = 0.007112; - ValueSpec input_scale(scale_val); - - int32_t zero_point_val = 0; - ValueSpec input_zero_point(zero_point_val); - - // For relu, output scale and zero point can differ from input - float output_scale_val = 0.007112; - ValueSpec output_scale(output_scale_val); - - int32_t output_zp_val = 0; - ValueSpec output_zero_point(output_zp_val); - - int32_t layout_int = static_cast(quant_layout); - ValueSpec layout_spec(layout_int); - - // Output tensor (float) - same shape as input - ValueSpec output_tensor( - config.shape, - input_dtype, - storage_type, - fp_memory_layout, - DataGenType::ZEROS); - - test_case.add_input_spec(input_tensor); - test_case.add_input_spec(input_scale); - test_case.add_input_spec(input_zero_point); - test_case.add_input_spec(output_scale); - test_case.add_input_spec(output_zero_point); - test_case.add_input_spec(layout_spec); - test_case.add_output_spec(output_tensor); - - test_case.set_abs_tolerance(scale_val + 1e-4); - - test_case.set_shader_filter({ - "nchw_to", - "to_nchw", - "q8ta_quantize", - "q8ta_dequantize", - }); - - return test_case; -} - -// Reference implementation: quantize -> relu -> dequantize -void q8ta_unary_reference_impl(TestCase& test_case) { - int32_t idx = 0; - const ValueSpec& input_spec = test_case.inputs()[idx++]; - const ValueSpec& input_scale_spec = test_case.inputs()[idx++]; - const ValueSpec& input_zp_spec = test_case.inputs()[idx++]; - const ValueSpec& output_scale_spec = test_case.inputs()[idx++]; - const ValueSpec& output_zp_spec = test_case.inputs()[idx++]; - const ValueSpec& layout_spec = test_case.inputs()[idx++]; - (void)layout_spec; - - ValueSpec& output_spec = test_case.outputs()[0]; - - auto input_sizes = input_spec.get_tensor_sizes(); - - int64_t num_elements = 1; - for (const auto& dim : input_sizes) { - num_elements *= dim; - } - - for (const auto& dim : input_sizes) { - if (dim > kRefDimSizeLimit) { - throw std::invalid_argument( - "One or more dimensions exceed the allowed limit for reference " - "implementation."); - } - } - - if (input_spec.dtype != vkapi::kFloat) { - throw std::invalid_argument("Unsupported dtype"); - } - - auto& input_data = input_spec.get_float_data(); - - float input_scale = input_scale_spec.get_float_value(); - int32_t input_zp = input_zp_spec.get_int_value(); - float output_scale = output_scale_spec.get_float_value(); - int32_t output_zp = output_zp_spec.get_int_value(); - int32_t quant_min = -128; - int32_t quant_max = 127; - - auto& ref_data = output_spec.get_ref_float_data(); - ref_data.resize(num_elements); - - for (int64_t i = 0; i < num_elements; ++i) { - float input_val = input_data[i]; - - // Quantize with input scale/zp - float quantized_float = std::round(input_val / input_scale) + input_zp; - quantized_float = std::max(quantized_float, static_cast(quant_min)); - quantized_float = std::min(quantized_float, static_cast(quant_max)); - int32_t quantized_int = static_cast(quantized_float); - - // Dequantize to float - float dequantized = (quantized_int - input_zp) * input_scale; - - // Apply ReLU - float activated = std::max(dequantized, 0.0f); - - // Requantize with output scale/zp - float requantized_float = std::round(activated / output_scale) + output_zp; - requantized_float = - std::max(requantized_float, static_cast(quant_min)); - requantized_float = - std::min(requantized_float, static_cast(quant_max)); - int32_t requantized_int = static_cast(requantized_float); - - // Dequantize back to float for comparison - ref_data[i] = (requantized_int - output_zp) * output_scale; - } -} - -} // namespace q8ta_unary -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/q8ta/unary/unary.h b/backends/vulkan/test/ops/benchmark/cases/q8ta/unary/unary.h deleted file mode 100644 index 2ff17187a8e..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/q8ta/unary/unary.h +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#pragma once - -#include -#include - -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace q8ta_unary { - -constexpr int64_t kRefDimSizeLimit = 512; - -struct Q8taUnaryConfig { - std::vector shape; - std::string test_case_name = "placeholder"; - std::string op_name = "q8ta_unary_test"; -}; - -TestCase create_test_case_from_config( - const Q8taUnaryConfig& config, - utils::StorageType storage_type, - vkapi::ScalarType input_dtype, - utils::GPUMemoryLayout fp_memory_layout, - utils::GPUMemoryLayout quant_layout); - -void q8ta_unary_reference_impl(TestCase& test_case); - -} // namespace q8ta_unary -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/sdpa/sdpa.cpp b/backends/vulkan/test/ops/benchmark/cases/sdpa/sdpa.cpp deleted file mode 100644 index cceb98c8916..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/sdpa/sdpa.cpp +++ /dev/null @@ -1,254 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include -#include -#include -#include -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace sdpa { - -TestCase create_sdpa_test_case( - const SDPAConfig& config, - vkapi::ScalarType dtype, - utils::StorageType storage_type, - const std::string& impl) { - TestCase test_case; - - const bool is_perf = config.context_len > kRefContextLenLimit; - const std::string prefix = is_perf ? "PERF" : "ACCU"; - const std::string storage_str = repr_str(storage_type, utils::kWidthPacked); - const std::string dtype_str = dtype_short(dtype); - - const std::string shape = "D" + std::to_string(config.head_dim) + " H" + - std::to_string(config.n_heads) + " Hkv" + - std::to_string(config.n_kv_heads) + " S" + - std::to_string(config.seq_len) + " C" + - std::to_string(config.context_len) + " P" + - std::to_string(config.input_pos >= 0 - ? config.input_pos - : config.context_len - config.seq_len); - - const std::string suffix = "[" + config.model + " " + config.regime + " " + - impl + " scale=" + - (config.scale.has_value() ? std::to_string(*config.scale) : "default") + - "]"; - - test_case.set_name(make_test_label( - prefix, dtype_str, dtype_str, shape, storage_str, suffix)); - test_case.set_operator_name("test_etvk.test_sdpa.default"); - - // q: [1, S, n_heads, head_dim] - ValueSpec q( - {1, config.seq_len, config.n_heads, config.head_dim}, - dtype, - storage_type, - utils::kWidthPacked, - DataGenType::RANDOM); - - // k_cache / v_cache: [1, context_len, n_kv_heads, head_dim] - ValueSpec k_cache( - {1, config.context_len, config.n_kv_heads, config.head_dim}, - dtype, - storage_type, - utils::kWidthPacked, - DataGenType::RANDOM); - ValueSpec v_cache( - {1, config.context_len, config.n_kv_heads, config.head_dim}, - dtype, - storage_type, - utils::kWidthPacked, - DataGenType::RANDOM); - ValueSpec k_projected( - {1, config.seq_len, config.n_kv_heads, config.head_dim}, - dtype, - storage_type, - utils::kWidthPacked, - DataGenType::RANDOM); - ValueSpec v_projected( - {1, config.seq_len, config.n_kv_heads, config.head_dim}, - dtype, - storage_type, - utils::kWidthPacked, - DataGenType::RANDOM); - const int64_t input_pos = config.input_pos >= 0 - ? config.input_pos - : config.context_len - config.seq_len; - ValueSpec input_pos_spec(utils::safe_downcast(input_pos)); - - ValueSpec scale; - if (config.scale.has_value()) { - scale = ValueSpec(*config.scale); - } else { - scale.set_none(true); - } - ValueSpec impl_selector = ValueSpec::make_string(impl); - - // out: [1, S, n_heads, head_dim] - ValueSpec output( - {1, config.seq_len, config.n_heads, config.head_dim}, - dtype, - storage_type, - utils::kWidthPacked, - DataGenType::ZEROS); - - test_case.add_input_spec(q); - test_case.add_input_spec(k_cache); - test_case.add_input_spec(v_cache); - test_case.add_input_spec(k_projected); - test_case.add_input_spec(v_projected); - test_case.add_input_spec(input_pos_spec); - test_case.add_input_spec(scale); - test_case.add_input_spec(impl_selector); - test_case.add_output_spec(output); - - if (dtype == vkapi::kHalf) { - test_case.set_abs_tolerance(1e-2f); - test_case.set_rel_tolerance(1e-2f); - } else { - test_case.set_abs_tolerance(1e-3f); - test_case.set_rel_tolerance(1e-3f); - } - - return test_case; -} - -// FLOPs: QK (2*S*C*D) + AV (2*S*C*D) per head, summed over heads. Softmax -// is negligible. Uses the causal-average context (~C/2) is ignored; report -// full-C dense FLOPs as an upper bound proxy. -int64_t sdpa_flop_calculator(const TestCase& test_case) { - const auto q_sizes = test_case.inputs()[0].get_tensor_sizes(); - const auto k_sizes = test_case.inputs()[1].get_tensor_sizes(); - const int64_t S = q_sizes[1]; - const int64_t H = q_sizes[2]; - const int64_t D = q_sizes[3]; - const int64_t C = k_sizes[1]; - return 4 * H * S * C * D; -} - -static std::vector as_float_data(const ValueSpec& spec) { - if (spec.dtype == vkapi::kFloat) { - return spec.get_float_data(); - } - if (spec.dtype == vkapi::kHalf) { - const auto& half_bits = spec.get_half_data(); - std::vector out(half_bits.size()); - for (size_t i = 0; i < half_bits.size(); ++i) { - out[i] = half_to_float(half_bits[i]); - } - return out; - } - throw std::invalid_argument("as_float_data: unsupported dtype"); -} - -// Reference: causal SDPA over the KV cache. -// q:[1,S,H,D], k/v cache:[1,C,Hkv,D], input_pos + S <= C. -// For query row s (absolute position input_pos + s), attends to cache -// positions [0, input_pos + s]. GQA: head h maps to kv head h / (H/Hkv). -void sdpa_reference_impl(TestCase& test_case) { - const auto& q = test_case.inputs()[0]; - const auto& k = test_case.inputs()[1]; - const auto& v = test_case.inputs()[2]; - - const auto q_sizes = q.get_tensor_sizes(); - const auto k_sizes = k.get_tensor_sizes(); - - const int64_t S = q_sizes[1]; - const int64_t H = q_sizes[2]; - const int64_t D = q_sizes[3]; - const int64_t C = k_sizes[1]; - const int64_t Hkv = k_sizes[2]; - - if (C > 512 || (C > kRefContextLenLimit && S > 18)) { - throw std::invalid_argument("sdpa reference: perf shape, skipping"); - } - - const int64_t input_pos = test_case.inputs()[5].get_int_value(); - const int64_t context_len = input_pos + S; - if (input_pos < 0 || context_len > C) { - throw std::invalid_argument("sdpa reference: invalid input_pos"); - } - const int64_t heads_per_kv = H / Hkv; - const ValueSpec& scale_spec = test_case.inputs()[6]; - const float scale = scale_spec.is_none() - ? 1.0f / std::sqrt(static_cast(D)) - : scale_spec.get_float_value(); - - const auto q_data = as_float_data(q); - auto k_data = as_float_data(k); - auto v_data = as_float_data(v); - if (test_case.inputs()[7].get_string_value() == "fused") { - const auto k_projected_data = as_float_data(test_case.inputs()[3]); - const auto v_projected_data = as_float_data(test_case.inputs()[4]); - for (int64_t s = 0; s < S; ++s) { - for (int64_t hk = 0; hk < Hkv; ++hk) { - for (int64_t d = 0; d < D; ++d) { - const int64_t projected_idx = (s * Hkv + hk) * D + d; - const int64_t cache_idx = ((input_pos + s) * Hkv + hk) * D + d; - k_data[cache_idx] = k_projected_data[projected_idx]; - v_data[cache_idx] = v_projected_data[projected_idx]; - } - } - } - } - - ValueSpec& output = test_case.outputs()[0]; - auto& ref = output.get_ref_float_data(); - ref.assign(S * H * D, 0.0f); - - // Index helpers (contiguous WHCN-flattened as [1, dim1, dim2, dim3]). - auto q_idx = [&](int64_t s, int64_t h, int64_t d) { - return (s * H + h) * D + d; - }; - auto kv_idx = [&](int64_t c, int64_t hk, int64_t d) { - return (c * Hkv + hk) * D + d; - }; - - std::vector scores(context_len); - for (int64_t s = 0; s < S; ++s) { - const int64_t attend_len = input_pos + s + 1; // causal - for (int64_t h = 0; h < H; ++h) { - const int64_t hk = h / heads_per_kv; - - float max_score = -std::numeric_limits::infinity(); - for (int64_t c = 0; c < attend_len; ++c) { - float dot = 0.0f; - for (int64_t d = 0; d < D; ++d) { - dot += q_data[q_idx(s, h, d)] * k_data[kv_idx(c, hk, d)]; - } - dot *= scale; - scores[c] = dot; - max_score = std::max(max_score, dot); - } - - float denom = 0.0f; - for (int64_t c = 0; c < attend_len; ++c) { - scores[c] = std::exp(scores[c] - max_score); - denom += scores[c]; - } - - for (int64_t d = 0; d < D; ++d) { - float acc = 0.0f; - for (int64_t c = 0; c < attend_len; ++c) { - acc += scores[c] * v_data[kv_idx(c, hk, d)]; - } - ref[q_idx(s, h, d)] = acc / denom; - } - } - } -} - -} // namespace sdpa -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/sdpa/sdpa.h b/backends/vulkan/test/ops/benchmark/cases/sdpa/sdpa.h deleted file mode 100644 index b1fb126afc9..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/sdpa/sdpa.h +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#pragma once - -#include -#include - -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace sdpa { - -// Correctness is only checked for these small shapes; larger perf shapes throw -// std::invalid_argument from the reference (framework marks them SKIPPED) to -// avoid an O(H*S*context*D) CPU reference on the large perf matrix. -constexpr int64_t kRefContextLenLimit = 256; - -// LLM SDPA (llama.custom_sdpa) shape: -// q: [1, S, n_heads, head_dim] (DHSB, width-packed) -// k/v cache:[1, context_len, n_kv_heads, head_dim] -struct SDPAConfig { - int64_t head_dim; - int64_t n_heads; - int64_t n_kv_heads; - int64_t seq_len; // S: query tokens (1 for decode, >1 for prefill) - int64_t context_len; // total KV length (kv_len) - std::string model; // label only - std::string regime; // "decode" / "prefill", label only - std::optional scale = std::nullopt; - int64_t input_pos = -1; -}; - -TestCase create_sdpa_test_case( - const SDPAConfig& config, - vkapi::ScalarType dtype, - utils::StorageType storage_type, - const std::string& impl); - -void sdpa_reference_impl(TestCase& test_case); - -int64_t sdpa_flop_calculator(const TestCase& test_case); - -} // namespace sdpa -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/cases/sdpa/test_sets/sdpa_sets.cpp b/backends/vulkan/test/ops/benchmark/cases/sdpa/test_sets/sdpa_sets.cpp deleted file mode 100644 index de1b23948d6..00000000000 --- a/backends/vulkan/test/ops/benchmark/cases/sdpa/test_sets/sdpa_sets.cpp +++ /dev/null @@ -1,287 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include - -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { -namespace sdpa { - -namespace { - -struct ModelDims { - std::string name; - int64_t head_dim; - int64_t n_heads; - int64_t n_kv_heads; -}; - -const std::vector kModels = { - {"Llama-3.2-1B", 64, 32, 8}, - {"Qwen3-0.6B", 128, 16, 8}, - {"Phi-4-mini", 128, 24, 8}, -}; - -// fp32 cases: each config yields one case per storage type x impl. -struct SDPACaseGroup { - std::vector configs; - std::vector storage_types; - std::vector impls; -}; - -struct SDPASetOptions { - // Swept over every model in kModels (fp16, texture). Each decode context - // length yields an S=1 case per decode impl; each prefill sequence length - // yields one S == context_len case. - std::vector decode_context_lens = {}; - std::vector decode_impls = {}; - std::vector prefill_seq_lens = {}; - // Appended after the model sweep. - std::vector accu_cases = {}; -}; - -std::vector generate_test_cases(const SDPASetOptions& options) { - std::vector test_cases; - - // Perf runs use fp16 texture (matches the LLM decode/prefill production - // path). A couple of small ACCU shapes validate correctness in fp32. - const auto dtype = vkapi::kHalf; - const auto storage = utils::kTexture3D; - - for (const auto& m : kModels) { - for (int64_t c : options.decode_context_lens) { - SDPAConfig cfg; - cfg.head_dim = m.head_dim; - cfg.n_heads = m.n_heads; - cfg.n_kv_heads = m.n_kv_heads; - cfg.seq_len = 1; - cfg.context_len = c; - cfg.model = m.name; - cfg.regime = "decode"; - for (const auto& impl : options.decode_impls) { - test_cases.push_back(create_sdpa_test_case(cfg, dtype, storage, impl)); - } - } - for (int64_t s : options.prefill_seq_lens) { - SDPAConfig cfg; - cfg.head_dim = m.head_dim; - cfg.n_heads = m.n_heads; - cfg.n_kv_heads = m.n_kv_heads; - cfg.seq_len = s; - cfg.context_len = s; - cfg.model = m.name; - cfg.regime = "prefill"; - // Prefill (tiled) is unaffected by the impl selector, so it runs a - // single case. - test_cases.push_back( - create_sdpa_test_case(cfg, dtype, storage, "default")); - } - } - - for (const auto& group : options.accu_cases) { - for (const auto& cfg : group.configs) { - for (const auto& st : group.storage_types) { - for (const auto& impl : group.impls) { - test_cases.push_back( - create_sdpa_test_case(cfg, vkapi::kFloat, st, impl)); - } - } - } - } - - return test_cases; -} - -} // namespace - -REGISTER_TEST_CASE_SET("sdpa", "correctness") { - return { - generate_test_cases({ - // Prefill: S == context_len. - .prefill_seq_lens = {128}, - // Small ACCU correctness cases (fp32), decode + prefill. Texture is - // the production LLM path; buffer is also validated for decode to - // guard the attn_weights S/context alignment (a decode-shaped buffer - // allocation has no headroom for the shaders' align_up_4 stride - // unless padded — see sdpa_impl). - .accu_cases = - { - {.configs = - { - {256, - 8, - 1, - 1, - 32, - "Gemma-4-E2B", - "sliding_decode", - 1.0f}, - {512, - 8, - 1, - 1, - 16, - "Gemma-4-E2B", - "full_decode", - 1.0f}, - {64, - 8, - 2, - 1, - 32, - "default_scale", - "decode", - std::nullopt}, - }, - .storage_types = {utils::kTexture3D}, - .impls = {"production"}}, - {.configs = - { - {256, - 8, - 1, - 1, - 32, - "Gemma-4-E2B", - "sliding_decode", - 1.0f}, - {512, - 8, - 1, - 1, - 16, - "Gemma-4-E2B", - "full_decode", - 1.0f}, - }, - .storage_types = {utils::kTexture3D}, - .impls = {"gqa_tile2", "gqa_base", "non_gqa"}}, - {.configs = - { - {256, - 8, - 1, - 4, - 4, - "Gemma-4-E2B", - "sliding_prefill", - 1.0f}, - {512, - 8, - 1, - 4, - 4, - "Gemma-4-E2B", - "full_prefill", - 1.0f}, - {256, - 8, - 1, - 18, - 512, - "Gemma-4-E2B", - "sliding_prefill_real", - 1.0f, - 0}, - {512, - 8, - 1, - 18, - 512, - "Gemma-4-E2B", - "full_prefill_real", - 1.0f, - 0}, - {256, - 8, - 1, - 1, - 512, - "Gemma-4-E2B", - "sliding_decode_prefix", - 1.0f, - 17}, - {512, - 8, - 1, - 1, - 512, - "Gemma-4-E2B", - "full_decode_prefix", - 1.0f, - 17}, - }, - .storage_types = {utils::kTexture3D}, - .impls = {"fused"}}, - // Cover D=64 (D4=16) and D=128 (D4=32) with the - // vendor-default GQA and the per-query-head shaders, across - // texture + buffer. - {.configs = - { - {64, 8, 2, 1, 32, "accu", "decode"}, - {128, 8, 2, 1, 32, "accu_d128", "decode"}, - }, - .storage_types = {utils::kTexture3D, utils::kBuffer}, - .impls = {"gqa", "non_gqa"}}, - // Force the head_dim output-tiled GQA variant (Adreno-only - // in production) so its wg x-collapse and the - // partial_n_tile tail get deterministic coverage on any - // device: D=64/128 give even D4 (fast path); D=4 gives D4=1 - // (odd), exercising the partial-tile checked load. - {.configs = - { - {64, 8, 2, 1, 32, "accu_tile2", "decode"}, - {128, 8, 2, 1, 32, "accu_tile2_d128", "decode"}, - {4, 8, 2, 1, 32, "accu_tile2_d4", "decode"}, - }, - .storage_types = {utils::kTexture3D, utils::kBuffer}, - .impls = {"gqa_tile2"}}, - {.configs = {{64, 8, 2, 16, 16, "accu", "prefill"}}, - .storage_types = {utils::kTexture3D}, - .impls = {"default"}}, - }, - }), - sdpa_reference_impl, - sdpa_flop_calculator, - /*warmup_runs=*/3, - /*benchmark_runs=*/10}; -} - -REGISTER_TEST_CASE_SET("sdpa", "decode") { - return { - generate_test_cases({ - // Decode: S=1, sweep context_len. - .decode_context_lens = {512, 1024, 4096}, - // Decode (S==1) picks a coop AV shader; exercise both the GQA-reuse - // variant and the per-query-head variant for every decode case. - .decode_impls = {"gqa", "non_gqa"}, - }), - sdpa_reference_impl, - sdpa_flop_calculator, - /*warmup_runs=*/10, - /*benchmark_runs=*/30}; -} - -REGISTER_TEST_CASE_SET("sdpa", "performance") { - return { - generate_test_cases({ - // Prefill: S == context_len. - .prefill_seq_lens = {512}, - }), - sdpa_reference_impl, - sdpa_flop_calculator, - /*warmup_runs=*/3, - /*benchmark_runs=*/10}; -} - -} // namespace sdpa -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/etvk_bench.cpp b/backends/vulkan/test/ops/benchmark/etvk_bench.cpp deleted file mode 100644 index 9b642d8dc2d..00000000000 --- a/backends/vulkan/test/ops/benchmark/etvk_bench.cpp +++ /dev/null @@ -1,247 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -// Runs the test case sets registered under cases/. See --helpshort for usage. - -#include -#include -#include -#include - -#include - -#include -#include -#include -#include -#include -#include -#include - -DEFINE_bool(list, false, "List the selected sets as : and exit."); -DEFINE_string( - op, - "", - "Comma-separated operators whose sets to run. An operator is its " - "directory under cases/; a parent directory such as q8ta selects every " - "operator beneath it. Empty selects all operators."); -DEFINE_string( - set, - "", - "Comma-separated set names to run. Empty selects all sets."); -DEFINE_string( - filter, - "", - "Run only test cases whose name matches the filter: '|'-separated " - "alternatives, each a ';'-separated list of substrings that must all " - "appear. For example, 'f16;+bias|f32' selects f16 cases with bias and all " - "f32 cases."); -DEFINE_int32( - chain, - 0, - "Dispatch the operator this many times per measured execute. 0 sizes the " - "chain adaptively."); -DEFINE_bool( - gpu_timestamps, - true, - "Time with GPU timestamps. --nogpu_timestamps uses the wall clock."); -DEFINE_bool(print_output, false, "Print output tensor data."); -DEFINE_bool(print_latencies, false, "Print per-iteration latencies."); -DEFINE_bool(debug, false, "Print debugging information."); -DEFINE_bool(device_info, false, "Print the Vulkan device properties and exit."); - -using namespace executorch::vulkan::prototyping; - -namespace { - -std::vector split(const std::string& value, char delimiter) { - std::vector items; - std::stringstream stream(value); - std::string item; - while (std::getline(stream, item, delimiter)) { - if (!item.empty()) { - items.push_back(item); - } - } - return items; -} - -bool op_selected(const std::vector& ops, const std::string& op) { - if (ops.empty()) { - return true; - } - for (const std::string& sel : ops) { - if (op == sel || op.rfind(sel + "/", 0) == 0) { - return true; - } - } - return false; -} - -bool set_selected( - const std::vector& sets, - const std::string& name) { - return sets.empty() || - std::find(sets.begin(), sets.end(), name) != sets.end(); -} - -// One list of required substrings per alternative. -using NameFilter = std::vector>; - -NameFilter parse_filter(const std::string& value) { - NameFilter filter; - for (const std::string& alternative : split(value, '|')) { - std::vector substrings = split(alternative, ';'); - if (!substrings.empty()) { - filter.push_back(std::move(substrings)); - } - } - return filter; -} - -bool contains_all( - const std::string& name, - const std::vector& substrings) { - for (const std::string& substring : substrings) { - if (name.find(substring) == std::string::npos) { - return false; - } - } - return true; -} - -bool name_matches(const NameFilter& filter, const std::string& name) { - if (filter.empty()) { - return true; - } - for (const std::vector& substrings : filter) { - if (contains_all(name, substrings)) { - return true; - } - } - return false; -} - -bool set_precedes( - const RegisteredTestCaseSet* a, - const RegisteredTestCaseSet* b) { - return a->op != b->op ? a->op < b->op : a->name < b->name; -} - -std::vector sorted_sets() { - std::vector sets; - for (const RegisteredTestCaseSet& set : registered_test_case_sets()) { - sets.push_back(&set); - } - std::sort(sets.begin(), sets.end(), set_precedes); - return sets; -} - -// Returns true if the set passed. -bool run_set( - const RegisteredTestCaseSet& registered, - const NameFilter& filter) { - const std::string id = registered.op + ":" + registered.name; - print_performance_header(); - std::cout << id << std::endl; - print_separator(); - - try { - TestCaseSet set = registered.make(); - if (set.custom_test) { - set.custom_test(); - return true; - } - std::vector test_cases; - for (TestCase& test_case : set.test_cases) { - if (!name_matches(filter, test_case.name())) { - continue; - } - if (FLAGS_chain > 0) { - test_case.set_op_invocations_per_execute(FLAGS_chain); - } - test_cases.push_back(std::move(test_case)); - } - execute_test_cases( - std::move(test_cases), - set.flop_calculator, - id, - set.warmup_runs, - set.benchmark_runs, - set.reference); - } catch (const std::exception& e) { - std::cerr << id << " failed: " << e.what() << std::endl; - return false; - } - return true; -} - -} // namespace - -int main(int argc, char* argv[]) { - gflags::SetUsageMessage( - "Runs the test case sets registered under cases/. With no selection " - "flags, every set runs."); - gflags::ParseCommandLineFlags(&argc, &argv, true); - if (argc > 1) { - std::cerr << "Unexpected argument: " << argv[1] << std::endl; - return 2; - } - if (FLAGS_chain < 0) { - std::cerr << "--chain must not be negative" << std::endl; - return 2; - } - - if (FLAGS_device_info) { - print_device_info(); - return 0; - } - - const std::vector ops = split(FLAGS_op, ','); - const std::vector set_names = split(FLAGS_set, ','); - std::vector selected; - for (const RegisteredTestCaseSet* set : sorted_sets()) { - if (op_selected(ops, set->op) && set_selected(set_names, set->name)) { - selected.push_back(set); - } - } - if (selected.empty()) { - std::cerr << "No test case sets match the selection" << std::endl; - return 2; - } - - if (FLAGS_list) { - for (const RegisteredTestCaseSet* set : selected) { - std::cout << set->op << ":" << set->name << std::endl; - } - return 0; - } - - set_debugging(FLAGS_debug); - set_print_output(FLAGS_print_output); - set_print_latencies(FLAGS_print_latencies); - set_use_gpu_timestamps(FLAGS_gpu_timestamps); - - const NameFilter filter = parse_filter(FLAGS_filter); - std::vector failed; - for (const RegisteredTestCaseSet* set : selected) { - if (!run_set(*set, filter)) { - failed.push_back(set->op + ":" + set->name); - } - } - - print_separator(); - std::cout << "Ran " << selected.size() << " test case sets"; - if (failed.empty()) { - std::cout << ", all passed" << std::endl; - } else { - std::cout << ", " << failed.size() << " failed" << std::endl; - } - for (const std::string& id : failed) { - std::cout << " FAILED: " << id << std::endl; - } - return failed.empty() ? 0 : 1; -} diff --git a/backends/vulkan/test/ops/benchmark/framework/cm_utils.cpp b/backends/vulkan/test/ops/benchmark/framework/cm_utils.cpp deleted file mode 100644 index 8668f2c9aa9..00000000000 --- a/backends/vulkan/test/ops/benchmark/framework/cm_utils.cpp +++ /dev/null @@ -1,139 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. - */ - -#include "cm_utils.h" - -#include -#include - -#include -#include -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { - -#ifdef VK_KHR_cooperative_matrix - -static std::string componentTypeToString(VkComponentTypeKHR type) { - switch (type) { - case VK_COMPONENT_TYPE_FLOAT16_KHR: - return "float16"; - case VK_COMPONENT_TYPE_FLOAT32_KHR: - return "float32"; - case VK_COMPONENT_TYPE_FLOAT64_KHR: - return "float64"; - case VK_COMPONENT_TYPE_SINT8_KHR: - return "int8"; - case VK_COMPONENT_TYPE_SINT16_KHR: - return "int16"; - case VK_COMPONENT_TYPE_SINT32_KHR: - return "int32"; - case VK_COMPONENT_TYPE_SINT64_KHR: - return "int64"; - case VK_COMPONENT_TYPE_UINT8_KHR: - return "uint8"; - case VK_COMPONENT_TYPE_UINT16_KHR: - return "uint16"; - case VK_COMPONENT_TYPE_UINT32_KHR: - return "uint32"; - case VK_COMPONENT_TYPE_UINT64_KHR: - return "uint64"; - default: - return "unknown(" + std::to_string(static_cast(type)) + ")"; - } -} - -static std::string scopeToString(VkScopeKHR scope) { - switch (scope) { - case VK_SCOPE_DEVICE_KHR: - return "Device"; - case VK_SCOPE_WORKGROUP_KHR: - return "Workgroup"; - case VK_SCOPE_SUBGROUP_KHR: - return "Subgroup"; - case VK_SCOPE_QUEUE_FAMILY_KHR: - return "QueueFamily"; - default: - return "unknown(" + std::to_string(static_cast(scope)) + ")"; - } -} - -#endif /* VK_KHR_cooperative_matrix */ - -void queryCooperativeMatrixProperties() { -#ifdef VK_KHR_cooperative_matrix - auto* adapter = vkcompute::api::context()->adapter_ptr(); - VkPhysicalDevice physicalDevice = adapter->physical_handle(); - - if (!adapter->supports_cooperative_matrix()) { - std::cout << "VK_KHR_cooperative_matrix is NOT supported on this device." - << std::endl; - return; - } - - std::cout << "\n=== Cooperative Matrix Properties (KHR) ===" << std::endl; - - uint32_t count = 0; - VkResult result = vkGetPhysicalDeviceCooperativeMatrixPropertiesKHR( - physicalDevice, &count, nullptr); - - if (result != VK_SUCCESS || count == 0) { - std::cout << "No cooperative matrix configurations found." << std::endl; - return; - } - - std::vector properties(count); - for (auto& prop : properties) { - prop.sType = VK_STRUCTURE_TYPE_COOPERATIVE_MATRIX_PROPERTIES_KHR; - prop.pNext = nullptr; - } - - result = vkGetPhysicalDeviceCooperativeMatrixPropertiesKHR( - physicalDevice, &count, properties.data()); - - if (result != VK_SUCCESS) { - std::cerr << "Failed to query cooperative matrix properties." << std::endl; - return; - } - - std::cout << "Found " << count << " cooperative matrix configurations:\n" - << std::endl; - - std::cout << std::left << std::setw(5) << "#" << std::setw(10) << "M" - << std::setw(10) << "N" << std::setw(10) << "K" << std::setw(12) - << "AType" << std::setw(12) << "BType" << std::setw(12) << "CType" - << std::setw(12) << "ResultType" << std::setw(12) << "Scope" - << std::endl; - - std::cout << std::string(95, '-') << std::endl; - - for (uint32_t i = 0; i < count; ++i) { - const auto& p = properties[i]; - std::cout << std::left << std::setw(5) << i << std::setw(10) << p.MSize - << std::setw(10) << p.NSize << std::setw(10) << p.KSize - << std::setw(12) << componentTypeToString(p.AType) - << std::setw(12) << componentTypeToString(p.BType) - << std::setw(12) << componentTypeToString(p.CType) - << std::setw(12) << componentTypeToString(p.ResultType) - << std::setw(12) << scopeToString(p.scope) << std::endl; - } - - std::cout << std::endl; - -#else - std::cout << "VK_KHR_cooperative_matrix not available at compile time." - << std::endl; -#endif -} - -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/framework/cm_utils.h b/backends/vulkan/test/ops/benchmark/framework/cm_utils.h deleted file mode 100644 index 1c6c06bdac8..00000000000 --- a/backends/vulkan/test/ops/benchmark/framework/cm_utils.h +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. - */ - -#pragma once - -namespace executorch { -namespace vulkan { -namespace prototyping { - -// Query and print VK_KHR_cooperative_matrix properties from the device. -// Shows supported M/N/K tile sizes, component types, and scopes. -void queryCooperativeMatrixProperties(); - -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/framework/config.cpp b/backends/vulkan/test/ops/benchmark/framework/config.cpp deleted file mode 100644 index ada88cb840b..00000000000 --- a/backends/vulkan/test/ops/benchmark/framework/config.cpp +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include "config.h" - -namespace executorch { -namespace vulkan { -namespace prototyping { - -// Output and latency printing utilities -namespace { -static int print_output_enabled = 0; -static int print_latencies_enabled = 0; -static int gpu_timestamps_enabled = 0; -static int debugging_enabled = 0; -} // namespace - -bool print_output() { - return print_output_enabled > 0; -} - -void set_print_output(bool print_output) { - print_output_enabled = print_output ? 1 : 0; -} - -bool print_latencies() { - return print_latencies_enabled > 0; -} - -void set_print_latencies(bool print_latencies) { - print_latencies_enabled = print_latencies ? 1 : 0; -} - -bool use_gpu_timestamps() { - return gpu_timestamps_enabled > 0; -} - -void set_use_gpu_timestamps(bool use_timestamps) { - gpu_timestamps_enabled = use_timestamps ? 1 : 0; -} - -bool debugging() { - return debugging_enabled > 0; -} - -void set_debugging(bool enable_debugging) { - debugging_enabled = enable_debugging ? 1 : 0; -} - -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/framework/config.h b/backends/vulkan/test/ops/benchmark/framework/config.h deleted file mode 100644 index 6b2a3475234..00000000000 --- a/backends/vulkan/test/ops/benchmark/framework/config.h +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#pragma once - -namespace executorch { -namespace vulkan { -namespace prototyping { - -// -// Global configuration options -// - -bool print_output(); -void set_print_output(bool print_output); - -bool print_latencies(); -void set_print_latencies(bool print_latencies); - -bool use_gpu_timestamps(); -void set_use_gpu_timestamps(bool use_timestamps); - -bool debugging(); -void set_debugging(bool enable_debugging); - -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/framework/conv2d_utils.cpp b/backends/vulkan/test/ops/benchmark/framework/conv2d_utils.cpp deleted file mode 100644 index 3dbbf0a4c0f..00000000000 --- a/backends/vulkan/test/ops/benchmark/framework/conv2d_utils.cpp +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include "conv2d_utils.h" - -namespace executorch { -namespace vulkan { -namespace prototyping { - -std::string make_test_case_conv_params_suffix(const Conv2dConfig& config) { - std::string suffix; - // Only print groups if not equal to 1 - if (config.groups != 1) { - suffix += "g=" + std::to_string(config.groups); - suffix += " "; - } - - suffix += "k="; - if (config.kernel.h == config.kernel.w) { - suffix += std::to_string(config.kernel.w); - } else { - suffix += - std::to_string(config.kernel.w) + "," + std::to_string(config.kernel.h); - } - // Only print stride if either dimension is not 1 - if (config.stride.h > 1 || config.stride.w > 1) { - suffix += ",s="; - if (config.stride.h == config.stride.w) { - suffix += std::to_string(config.stride.w); - } else { - suffix += std::to_string(config.stride.w) + "," + - std::to_string(config.stride.h); - } - } - // Only print padding if either dimension is not 1 - if (config.padding.h != 1 || config.padding.w != 1) { - suffix += ",p="; - if (config.padding.h == config.padding.w) { - suffix += std::to_string(config.padding.w); - } else { - suffix += std::to_string(config.padding.w) + "," + - std::to_string(config.padding.h); - } - } - // Only print dilation if either dimension is not 1 - if (config.dilation.h != 1 || config.dilation.w != 1) { - suffix += ",d="; - if (config.dilation.h == config.dilation.w) { - suffix += std::to_string(config.dilation.w); - } else { - suffix += std::to_string(config.dilation.w) + "," + - std::to_string(config.dilation.h); - } - } - return suffix; -} - -std::string to_string(const vkcompute::utils::StorageType storage_type) { - switch (storage_type) { - case vkcompute::utils::kTexture3D: - return "Tex"; - case vkcompute::utils::kTexture2D: - return "Tex2D"; - case vkcompute::utils::kBuffer: - return "Buf"; - } -} - -std::string make_test_case_name( - const Conv2dConfig& config, - const bool is_performance, - const vkcompute::utils::StorageType fp_storage_type, - const vkcompute::utils::StorageType int8_storage_type) { - std::string test_case_name = is_performance ? "PERF " : "ACCU "; - test_case_name += std::to_string(config.channels.in) + "->" + - std::to_string(config.channels.out) + - " I=" + std::to_string(config.input_size.h) + "," + - std::to_string(config.input_size.w) + " " + - make_test_case_conv_params_suffix(config); - - test_case_name += - " " + to_string(fp_storage_type) + "->" + to_string(int8_storage_type); - - return test_case_name; -} - -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/framework/conv2d_utils.h b/backends/vulkan/test/ops/benchmark/framework/conv2d_utils.h deleted file mode 100644 index 34853ec383a..00000000000 --- a/backends/vulkan/test/ops/benchmark/framework/conv2d_utils.h +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#pragma once - -#include - -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { - -// Component structs for better readability -struct KernelSize { - int32_t h; - int32_t w; - - KernelSize(int32_t height, int32_t width) : h(height), w(width) {} -}; - -struct Stride { - int32_t h; - int32_t w; - - Stride(int32_t height, int32_t width) : h(height), w(width) {} -}; - -struct Padding { - int32_t h; - int32_t w; - - Padding(int32_t height, int32_t width) : h(height), w(width) {} -}; - -struct Dilation { - int32_t h; - int32_t w; - - Dilation(int32_t height = 1, int32_t width = 1) : h(height), w(width) {} -}; - -struct OutInChannels { - int32_t out; - int32_t in; - - OutInChannels(int32_t out_channels, int32_t in_channels) - : out(out_channels), in(in_channels) {} -}; - -struct InputSize2D { - int32_t h; - int32_t w; - - InputSize2D(int32_t height, int32_t width) : h(height), w(width) {} -}; - -// Conv2d configuration struct -struct Conv2dConfig { - OutInChannels channels; - InputSize2D input_size; - KernelSize kernel; - Stride stride; - Padding padding; - Dilation dilation; - int32_t groups; // Number of groups for grouped convolution - int32_t batch = 1; - std::string test_case_name = "placeholder"; - std::string op_name = "conv2d"; - - // Calculate output dimensions - int64_t get_output_height() const { - return (input_size.h + 2 * padding.h - dilation.h * (kernel.h - 1) - 1) / - stride.h + - 1; - } - - int64_t get_output_width() const { - return (input_size.w + 2 * padding.w - dilation.w * (kernel.w - 1) - 1) / - stride.w + - 1; - } -}; - -std::string make_test_case_name( - const Conv2dConfig& config, - const bool is_performance, - const vkcompute::utils::StorageType fp_storage_type, - const vkcompute::utils::StorageType int8_storage_type); - -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/framework/device_info.cpp b/backends/vulkan/test/ops/benchmark/framework/device_info.cpp deleted file mode 100644 index 5275b7c6121..00000000000 --- a/backends/vulkan/test/ops/benchmark/framework/device_info.cpp +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include "device_info.h" -#include "cm_utils.h" - -#include - -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { - -void print_device_info() { - auto* adapter = vkcompute::api::context()->adapter_ptr(); - - std::cout << "=== Vulkan adapter ===\n"; - std::cout << "device_name : " << adapter->device_name() << "\n"; - std::cout << "is_integrated_gpu : " - << (adapter->is_integrated_gpu() ? "yes" : "no") << "\n"; - std::cout << "subgroup_size : " << adapter->subgroup_size() - << "\n"; - std::cout << "min_subgroup_size : " << adapter->min_subgroup_size() - << "\n"; - std::cout << "max_subgroup_size : " << adapter->max_subgroup_size() - << "\n"; - std::cout << "supports_cooperative_mat : " - << (adapter->supports_cooperative_matrix() ? "yes" : "no") << "\n"; - std::cout << "supports_int8_dot_product: " - << (adapter->supports_int8_dot_product() ? "yes" : "no") << "\n"; - - queryCooperativeMatrixProperties(); -} - -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/framework/device_info.h b/backends/vulkan/test/ops/benchmark/framework/device_info.h deleted file mode 100644 index 9094ca8d5cb..00000000000 --- a/backends/vulkan/test/ops/benchmark/framework/device_info.h +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#pragma once - -namespace executorch { -namespace vulkan { -namespace prototyping { - -// Prints the active Vulkan adapter's properties and the cooperative matrix -// configurations it exposes. -void print_device_info(); - -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/framework/labels.h b/backends/vulkan/test/ops/benchmark/framework/labels.h deleted file mode 100644 index bcae57ccef8..00000000000 --- a/backends/vulkan/test/ops/benchmark/framework/labels.h +++ /dev/null @@ -1,136 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#pragma once - -#include - -#include -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { - -// NOLINTNEXTLINE(google-build-using-namespace) -using namespace vkcompute; - -// -// String utilities -// - -// Helper function to get abbreviated layout names for test case naming -inline std::string layout_abbrev(utils::GPUMemoryLayout layout) { - // NOLINTNEXTLINE(clang-diagnostic-switch-enum) - switch (layout) { - case utils::kWidthPacked: - return "WP"; - case utils::kChannelsPacked: - return "CP"; - case utils::kPackedInt8_4W: - return "4W"; - case utils::kPackedInt8_4C: - return "4C"; - case utils::kPackedInt8_4W4C: - return "4W4C"; - case utils::kPackedInt8_4H4W: - return "4H4W"; - case utils::kPackedInt8_4C1W: - return "4C1W"; - default: - return "UNK"; - } -} - -// Helper function to get abbreviated storage type names for test case naming -inline std::string storage_type_abbrev(utils::StorageType storage_type) { - // NOLINTNEXTLINE(clang-diagnostic-switch-enum) - switch (storage_type) { - case utils::kTexture3D: - return "Tex"; - case utils::kBuffer: - return "Buf"; - default: - return "UNK"; - } -} - -// Helper function to get combined storage type and layout representation -// Example: (kBuffer, kPackedInt8_4W4C) -> "Buf_4W4C" -inline std::string repr_str( - utils::StorageType storage_type, - utils::GPUMemoryLayout layout) { - return storage_type_abbrev(storage_type) + "(" + layout_abbrev(layout) + ")"; -} - -// Helper function to generate comma-separated shape string for test case naming -// Example: {1, 128, 56, 56} -> "1,128,56,56" -inline std::string shape_string(const std::vector& shape) { - std::string result; - for (size_t i = 0; i < shape.size(); ++i) { - if (i > 0) { - result += ","; - } - result += std::to_string(shape[i]); - } - return result; -} - -// Helper function to generate a bracketed shape string for test case naming -// Example: {1, 128, 56, 56} -> "[1,128,56,56]" -inline std::string shape_bracket(const std::vector& shape) { - return "[" + shape_string(shape) + "]"; -} - -// Short dtype symbol used in standardized test case labels -// Example: kFloat -> "f32", kHalf -> "f16", kChar -> "i8", kInt -> "i32" -inline std::string dtype_short(vkapi::ScalarType dtype) { - // NOLINTNEXTLINE(clang-diagnostic-switch-enum) - switch (dtype) { - case vkapi::kFloat: - return "f32"; - case vkapi::kHalf: - return "f16"; - case vkapi::kChar: - return "i8"; - case vkapi::kByte: - return "u8"; - case vkapi::kInt: - return "i32"; - case vkapi::kBool: - return "b"; - default: - return "?"; - } -} - -// Build a standardized test label of the form: -// " [ ]" -// where is "->" when the two differ, or just -// "" when they match. Sections are separated by two spaces. If -// suffix is non-empty it is appended after a single space (allowing callers -// to pass e.g. "[general]" or "[gemv] +bias"). -inline std::string make_test_label( - const std::string& prefix, - const std::string& in_dtype, - const std::string& out_dtype, - const std::string& shape_str, - const std::string& storage_str, - const std::string& suffix = "") { - const std::string dtype_str = - (in_dtype == out_dtype) ? in_dtype : in_dtype + "->" + out_dtype; - std::string label = - prefix + " " + dtype_str + " " + shape_str + " " + storage_str; - if (!suffix.empty()) { - label += " " + suffix; - } - return label; -} - -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/framework/registry.cpp b/backends/vulkan/test/ops/benchmark/framework/registry.cpp deleted file mode 100644 index da37f972e70..00000000000 --- a/backends/vulkan/test/ops/benchmark/framework/registry.cpp +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include "registry.h" - -#include - -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { - -namespace { - -std::vector& registry() { - static std::vector sets; - return sets; -} - -} // namespace - -const std::vector& registered_test_case_sets() { - return registry(); -} - -TestCaseSetRegistrar::TestCaseSetRegistrar( - std::string op, - std::string name, - std::function make) { - for (const RegisteredTestCaseSet& set : registry()) { - VK_CHECK_COND( - set.op != op || set.name != name, - "Test case set ", - op, - ":", - name, - " is registered more than once"); - } - registry().push_back({std::move(op), std::move(name), std::move(make)}); -} - -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/framework/registry.h b/backends/vulkan/test/ops/benchmark/framework/registry.h deleted file mode 100644 index ca24db5f47c..00000000000 --- a/backends/vulkan/test/ops/benchmark/framework/registry.h +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#pragma once - -#include "runner.h" -#include "test_case.h" - -#include -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { - -struct TestCaseSet { - std::vector test_cases; - ReferenceComputeFunc reference = nullptr; - FlopCalculatorFunc flop_calculator = default_flop_calculator; - int warmup_runs = 1; - int benchmark_runs = 1; - // Runs instead of test_cases for checks that are not expressible as test - // cases. Throws on failure. - std::function custom_test = nullptr; -}; - -struct RegisteredTestCaseSet { - // Path of the operator's directory under cases/, e.g. "q8ta/conv2d". - std::string op; - std::string name; - // Invoked only when the set is selected, since building test cases may - // query the device. - std::function make; -}; - -const std::vector& registered_test_case_sets(); - -class TestCaseSetRegistrar final { - public: - TestCaseSetRegistrar( - std::string op, - std::string name, - std::function make); -}; - -#define ETVK_TEST_CASE_SET_CONCAT_IMPL(a, b) a##b -#define ETVK_TEST_CASE_SET_CONCAT(a, b) ETVK_TEST_CASE_SET_CONCAT_IMPL(a, b) - -// Registers the TestCaseSet returned by the function body that follows. -#define REGISTER_TEST_CASE_SET(op, name) \ - static ::executorch::vulkan::prototyping::TestCaseSet \ - ETVK_TEST_CASE_SET_CONCAT(make_test_case_set_, __LINE__)(); \ - static const ::executorch::vulkan::prototyping::TestCaseSetRegistrar \ - ETVK_TEST_CASE_SET_CONCAT(test_case_set_registrar_, __LINE__)( \ - op, \ - name, \ - &ETVK_TEST_CASE_SET_CONCAT(make_test_case_set_, __LINE__)); \ - static ::executorch::vulkan::prototyping::TestCaseSet \ - ETVK_TEST_CASE_SET_CONCAT(make_test_case_set_, __LINE__)() - -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/framework/results.cpp b/backends/vulkan/test/ops/benchmark/framework/results.cpp deleted file mode 100644 index 3a71c50237b..00000000000 --- a/backends/vulkan/test/ops/benchmark/framework/results.cpp +++ /dev/null @@ -1,468 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include "results.h" - -#include -#include -#include -#include -#include -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { - -// BenchmarkResult implementation -void BenchmarkResult::add_iter_timing(float time_us) { - iter_timings.push_back(time_us); -} - -void BenchmarkResult::add_shader_timing( - const std::string& shader_name, - float time_us, - const uint32_t gwg[3], - const uint32_t lwg[3]) { - // Find existing shader timing or create new one - for (auto& st : shader_timings_) { - if (st.shader_name == shader_name) { - st.iter_timings_us.push_back(time_us); - // Work group sizes should be consistent across iterations - return; - } - } - // Not found, create new entry - ShaderTiming new_timing; - new_timing.shader_name = shader_name; - new_timing.iter_timings_us.push_back(time_us); - new_timing.gwg[0] = gwg[0]; - new_timing.gwg[1] = gwg[1]; - new_timing.gwg[2] = gwg[2]; - new_timing.lwg[0] = lwg[0]; - new_timing.lwg[1] = lwg[1]; - new_timing.lwg[2] = lwg[2]; - shader_timings_.push_back(std::move(new_timing)); -} - -float BenchmarkResult::get_avg_time_us() const { - if (iter_timings.empty()) { - return 0.0f; - } - - float sum = 0.0f; - for (float timing : iter_timings) { - sum += timing; - } - return sum / iter_timings.size(); -} - -float BenchmarkResult::get_min_time_us() const { - if (iter_timings.empty()) { - return 0.0f; - } - - return *std::min_element(iter_timings.begin(), iter_timings.end()); -} - -float BenchmarkResult::get_max_time_us() const { - if (iter_timings.empty()) { - return 0.0f; - } - - return *std::max_element(iter_timings.begin(), iter_timings.end()); -} - -float BenchmarkResult::get_std_dev_us() const { - if (iter_timings.size() <= 1) { - return 0.0f; - } - - float mean = get_avg_time_us(); - float sum_sq_diff = 0.0f; - - for (float timing : iter_timings) { - float diff = timing - mean; - sum_sq_diff += diff * diff; - } - - return std::sqrt(sum_sq_diff / (iter_timings.size() - 1)); -} - -void BenchmarkResult::print_summary( - int case_number, - const std::string& size_info, - float total_gflops) const { - (void)case_number; - static constexpr int OPERATOR_NAME_WIDTH = 50; - static constexpr int GLOBAL_WG_WIDTH = 16; - static constexpr int LOCAL_WG_WIDTH = 12; - static constexpr int KERNEL_NAME_WIDTH = 80; - static constexpr int SIZE_INFO_WIDTH = 20; - static constexpr int TIMING_WIDTH = 16; - static constexpr int GFLOPS_WIDTH = 14; - static constexpr int CORRECTNESS_WIDTH = 8; - - // Helper to truncate shader names longer than 46 chars to 44 chars + ".." - auto truncate_shader_name = [](const std::string& name) -> std::string { - if (name.length() > 46) { - return name.substr(0, 44) + ".."; - } - return name; - }; - - // Helper to format work group size as (x,y,z) - auto format_wg_size = [](const uint32_t wg[3]) -> std::string { - return "(" + std::to_string(wg[0]) + "," + std::to_string(wg[1]) + "," + - std::to_string(wg[2]) + ")"; - }; - - std::string correctness_str; - switch (correctness_status_) { - case CorrectnessStatus::SKIPPED: - correctness_str = "SKIPPED"; - break; - case CorrectnessStatus::PASSED: - correctness_str = "PASSED"; - break; - case CorrectnessStatus::FAILED: - correctness_str = "FAILED"; - break; - } - - // If we have per-shader timing data, print one line per shader plus overall - if (!shader_timings_.empty()) { - // If only one shader, print a single combined row - if (shader_timings_.size() == 1) { - const auto& st = shader_timings_[0]; - std::cout << std::left << std::setw(OPERATOR_NAME_WIDTH) - << truncate_shader_name(st.shader_name) << " " << std::left - << std::setw(GLOBAL_WG_WIDTH) << format_wg_size(st.gwg) - << std::left << std::setw(LOCAL_WG_WIDTH) - << format_wg_size(st.lwg) << std::left - << std::setw(KERNEL_NAME_WIDTH) << get_kernel_name() - << std::right << " " << std::setw(SIZE_INFO_WIDTH) << size_info - << std::setw(TIMING_WIDTH) << std::fixed << std::setprecision(3) - << get_avg_time_us() << " μs " << std::setw(GFLOPS_WIDTH) - << std::fixed << std::setprecision(3) << total_gflops - << " GFLOP/s " << std::setw(CORRECTNESS_WIDTH) - << correctness_str << std::endl; - } else { - // Multiple shaders: print individual shader lines (without GFLOP/s) - for (size_t i = 0; i < shader_timings_.size(); ++i) { - const auto& st = shader_timings_[i]; - float shader_avg_time = st.get_avg_time_us(); - - // Shader lines don't show test case info - std::cout << std::left << std::setw(OPERATOR_NAME_WIDTH) - << truncate_shader_name(st.shader_name) << " " << std::left - << std::setw(GLOBAL_WG_WIDTH) << format_wg_size(st.gwg) - << std::left << std::setw(LOCAL_WG_WIDTH) - << format_wg_size(st.lwg) << std::left - << std::setw(KERNEL_NAME_WIDTH) << "" << std::right << " " - << std::setw(SIZE_INFO_WIDTH) << "" << std::setw(TIMING_WIDTH) - << std::fixed << std::setprecision(3) << shader_avg_time - << " μs " << std::setw(GFLOPS_WIDTH) << "" << " " - << std::setw(CORRECTNESS_WIDTH) << "" << std::endl; - } - - // Print overall row with operator name, test case info, total time, and - // GFLOP/s - std::cout << std::left << std::setw(OPERATOR_NAME_WIDTH) - << get_operator_name() << " " << std::left - << std::setw(GLOBAL_WG_WIDTH) << "" << std::left - << std::setw(LOCAL_WG_WIDTH) << "" << std::left - << std::setw(KERNEL_NAME_WIDTH) << get_kernel_name() - << std::right << " " << std::setw(SIZE_INFO_WIDTH) << size_info - << std::setw(TIMING_WIDTH) << std::fixed << std::setprecision(3) - << get_avg_time_us() << " μs " << std::setw(GFLOPS_WIDTH) - << std::fixed << std::setprecision(3) << total_gflops - << " GFLOP/s " << std::setw(CORRECTNESS_WIDTH) - << correctness_str << std::endl; - } - - // Print separator line between test cases - } else { - // No per-shader timing data, use the original format - std::cout << std::left << std::setw(OPERATOR_NAME_WIDTH) - << get_operator_name() << " " << std::left - << std::setw(GLOBAL_WG_WIDTH) << "" << std::left - << std::setw(LOCAL_WG_WIDTH) << "" << std::left - << std::setw(KERNEL_NAME_WIDTH) << get_kernel_name() << std::right - << " " << std::setw(SIZE_INFO_WIDTH) << size_info - << std::setw(TIMING_WIDTH) << std::fixed << std::setprecision(3) - << get_avg_time_us() << " μs " << std::setw(GFLOPS_WIDTH) - << std::fixed << std::setprecision(3) << total_gflops - << " GFLOP/s " << std::setw(CORRECTNESS_WIDTH) << correctness_str - << std::endl; - } -} - -// TestResult implementation -void TestResult::add_result(const BenchmarkResult& result) { - results_.push_back(result); -} - -void TestResult::add_result(BenchmarkResult&& result) { - results_.push_back(std::move(result)); -} - -void TestResult::print_summary() const { - static constexpr int CASE_WIDTH = 100; - static constexpr int KERNEL_NAME_WIDTH = 20; - static constexpr int TIMING_WIDTH = 12; - static constexpr int PASS_WIDTH = 8; - - if (results_.empty()) { - std::cout << "No results to display" << std::endl; - return; - } - - std::cout << "\n=== " << operation_name_ - << " Performance Summary ===" << std::endl; - print_separator(); - - std::cout << std::left << std::setw(CASE_WIDTH) << "Case" << std::left - << std::setw(KERNEL_NAME_WIDTH) << "Kernel Name" << std::left - << std::setw(TIMING_WIDTH) << "Avg (μs)" << std::left - << std::setw(TIMING_WIDTH) << "Min (μs)" << std::left - << std::setw(TIMING_WIDTH) << "Max (μs)" << std::left - << std::setw(TIMING_WIDTH) << "Std Dev" << std::left - << std::setw(PASS_WIDTH) << "Pass" << std::endl; - print_separator(); - - for (size_t i = 0; i < results_.size(); ++i) { - const auto& result = results_[i]; - bool vulkan_execute_succeeded = - result.get_num_iterations() > 0 && result.get_avg_time_us() > 0.0f; - std::cout << std::left << std::setw(CASE_WIDTH) << i + 1 << std::left - << std::setw(KERNEL_NAME_WIDTH) - << result.get_kernel_name().substr(0, KERNEL_NAME_WIDTH - 1) - << std::left << std::setw(TIMING_WIDTH) << std::fixed - << std::setprecision(3) << result.get_avg_time_us() << std::left - << std::setw(TIMING_WIDTH) << std::fixed << std::setprecision(3) - << result.get_min_time_us() << std::left - << std::setw(TIMING_WIDTH) << std::fixed << std::setprecision(3) - << result.get_max_time_us() << std::left - << std::setw(TIMING_WIDTH) << std::fixed << std::setprecision(3) - << result.get_std_dev_us() << std::left << std::setw(PASS_WIDTH) - << (vulkan_execute_succeeded ? "✓" : "✗") << std::endl; - } - - print_separator(); - std::cout << "Total cases: " << results_.size() - << ", Passed: " << get_passed_count() - << ", Failed: " << get_failed_count() << std::endl; - std::cout << "Overall GFLOP/s: " << std::fixed << std::setprecision(3) - << gflops_ << std::endl; - std::cout << "Overall correctness: " - << (correctness_passed_ ? "PASSED" : "FAILED") << std::endl; -} - -void TestResult::print_detailed_results() const { - if (results_.empty()) { - std::cout << "No results to display" << std::endl; - return; - } - - std::cout << "\n=== " << operation_name_ - << " Detailed Results ===" << std::endl; - - for (size_t i = 0; i < results_.size(); ++i) { - const auto& result = results_[i]; - bool vulkan_execute_succeeded = - result.get_num_iterations() > 0 && result.get_avg_time_us() > 0.0f; - std::cout << "\nCase " << i + 1 << ": " << result.get_kernel_name() - << std::endl; - std::cout << " Iterations: " << result.get_num_iterations() << std::endl; - std::cout << " Average: " << std::fixed << std::setprecision(3) - << result.get_avg_time_us() << " μs" << std::endl; - std::cout << " Min: " << std::fixed << std::setprecision(3) - << result.get_min_time_us() << " μs" << std::endl; - std::cout << " Max: " << std::fixed << std::setprecision(3) - << result.get_max_time_us() << " μs" << std::endl; - std::cout << " Std Dev: " << std::fixed << std::setprecision(3) - << result.get_std_dev_us() << " μs" << std::endl; - std::cout << " Correctness: " - << (vulkan_execute_succeeded ? "PASSED" : "FAILED") << std::endl; - - if (result.get_num_iterations() > 0) { - std::cout << " Individual timings (μs): "; - const auto& timings = result.get_iter_timings(); - for (size_t j = 0; j < std::min(size_t(10), timings.size()); ++j) { - std::cout << std::fixed << std::setprecision(1) << timings[j]; - if (j < std::min(size_t(10), timings.size()) - 1) { - std::cout << ", "; - } - } - if (timings.size() > 10) { - std::cout << " ... (" << (timings.size() - 10) << " more)"; - } - std::cout << std::endl; - } - } - - std::cout << "\nOverall Results:" << std::endl; - std::cout << " Total GFLOP/s: " << std::fixed << std::setprecision(3) - << gflops_ << std::endl; - std::cout << " Overall correctness: " - << (correctness_passed_ ? "PASSED" : "FAILED") << std::endl; -} - -void TestResult::print_statistics() const { - if (results_.empty()) { - std::cout << "No results to display statistics for" << std::endl; - return; - } - - std::cout << "\n=== " << operation_name_ << " Statistics ===" << std::endl; - std::cout << "Total test cases: " << results_.size() << std::endl; - std::cout << "Passed: " << get_passed_count() << std::endl; - std::cout << "Failed: " << get_failed_count() << std::endl; - std::cout << "Success rate: " << std::fixed << std::setprecision(1) - << (100.0f * get_passed_count() / results_.size()) << "%" - << std::endl; - - if (get_passed_count() > 0) { - std::cout << "Total average time: " << std::fixed << std::setprecision(3) - << get_total_avg_time_us() << " μs" << std::endl; - std::cout << "Total GFLOP/s: " << std::fixed << std::setprecision(3) - << get_total_gflops() << std::endl; - - const auto* fastest = get_fastest_result(); - const auto* slowest = get_slowest_result(); - const auto* highest_gflops = get_highest_gflops_result(); - - if (fastest) { - std::cout << "Fastest case: " << fastest->get_kernel_name() << " (" - << std::fixed << std::setprecision(3) - << fastest->get_avg_time_us() << " μs)" << std::endl; - } - - if (slowest) { - std::cout << "Slowest case: " << slowest->get_kernel_name() << " (" - << std::fixed << std::setprecision(3) - << slowest->get_avg_time_us() << " μs)" << std::endl; - } - - if (highest_gflops) { - std::cout << "Best performing case: " << highest_gflops->get_kernel_name() - << " (" << std::fixed << std::setprecision(3) - << highest_gflops->get_avg_time_us() << " μs)" << std::endl; - } - } -} - -void TestResult::print_brief_summary() const { - print_separator(); - std::cout << "Summary Statistics:" << std::endl; - - if (get_passed_count() > 0) { - std::cout << "Average execution time: " << std::fixed - << std::setprecision(3) << get_total_avg_time_us() << " μs" - << std::endl; - std::cout << "Total throughput: " << std::fixed << std::setprecision(3) - << get_gflops() << " GFLOP/s" << std::endl; - std::cout << "Successful test cases: " << get_passed_count() << "/" - << size() << std::endl; - std::cout << "Overall correctness: " - << (get_correctness_passed() ? "PASSED" : "FAILED") << std::endl; - } else { - std::cout << "No successful test cases to report" << std::endl; - } -} - -float TestResult::get_total_avg_time_us() const { - if (results_.empty()) { - return 0.0f; - } - - float sum = 0.0f; - size_t count = 0; - - for (const auto& result : results_) { - bool vulkan_execute_succeeded = - result.get_num_iterations() > 0 && result.get_avg_time_us() > 0.0f; - if (vulkan_execute_succeeded) { - sum += result.get_avg_time_us(); - count++; - } - } - - return count > 0 ? sum / count : 0.0f; -} - -float TestResult::get_total_gflops() const { - return gflops_; -} - -size_t TestResult::get_passed_count() const { - size_t count = 0; - for (const auto& result : results_) { - bool vulkan_execute_succeeded = - result.get_num_iterations() > 0 && result.get_avg_time_us() > 0.0f; - if (vulkan_execute_succeeded) { - count++; - } - } - return count; -} - -size_t TestResult::get_failed_count() const { - return results_.size() - get_passed_count(); -} - -const BenchmarkResult* TestResult::get_fastest_result() const { - const BenchmarkResult* fastest = nullptr; - - for (const auto& result : results_) { - bool vulkan_execute_succeeded = - result.get_num_iterations() > 0 && result.get_avg_time_us() > 0.0f; - if (vulkan_execute_succeeded) { - if (!fastest || result.get_avg_time_us() < fastest->get_avg_time_us()) { - fastest = &result; - } - } - } - - return fastest; -} - -const BenchmarkResult* TestResult::get_slowest_result() const { - const BenchmarkResult* slowest = nullptr; - - for (const auto& result : results_) { - bool vulkan_execute_succeeded = - result.get_num_iterations() > 0 && result.get_avg_time_us() > 0.0f; - if (vulkan_execute_succeeded) { - if (!slowest || result.get_avg_time_us() > slowest->get_avg_time_us()) { - slowest = &result; - } - } - } - - return slowest; -} - -const BenchmarkResult* TestResult::get_highest_gflops_result() const { - // Since GFLOPS is now a TestResult-level metric rather than per-case, - // this method returns the fastest result as a proxy for highest performance - return get_fastest_result(); -} - -// Utility functions for printing -void print_performance_header() { - std::cout << "\n=== Compute Shader Performance Benchmark ===" << std::endl; -} - -void print_separator() { - std::cout << std::string(70, '-') << std::endl; -} - -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/framework/results.h b/backends/vulkan/test/ops/benchmark/framework/results.h deleted file mode 100644 index e957ddf8bce..00000000000 --- a/backends/vulkan/test/ops/benchmark/framework/results.h +++ /dev/null @@ -1,235 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#pragma once - -#include -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { - -// -// BenchmarkResult -// - -enum class CorrectnessStatus { - SKIPPED, // No reference function provided - PASSED, // Reference function provided and validation passed - FAILED // Reference function provided but validation failed -}; - -// Per-shader timing data for detailed reporting -struct ShaderTiming { - std::string shader_name; - std::vector iter_timings_us; // Individual iteration timings - uint32_t gwg[3] = {0, 0, 0}; - uint32_t lwg[3] = {0, 0, 0}; - - float get_avg_time_us() const { - if (iter_timings_us.empty()) { - return 0.0f; - } - float sum = 0.0f; - for (float t : iter_timings_us) { - sum += t; - } - return sum / iter_timings_us.size(); - } -}; - -class BenchmarkResult { - public: - BenchmarkResult() : correctness_status_(CorrectnessStatus::SKIPPED) {} - - explicit BenchmarkResult(const std::string& name) - : kernel_name(name), correctness_status_(CorrectnessStatus::SKIPPED) {} - - BenchmarkResult( - const std::string& kernel_name, - const std::string& operator_name) - : kernel_name(kernel_name), - operator_name(operator_name), - correctness_status_(CorrectnessStatus::SKIPPED) {} - - // Add timing for a single iteration - void add_iter_timing(float time_us); - - // Add per-shader timing for a single iteration - void add_shader_timing( - const std::string& shader_name, - float time_us, - const uint32_t gwg[3], - const uint32_t lwg[3]); - - // Get per-shader timing data - const std::vector& get_shader_timings() const { - return shader_timings_; - } - - // Getters - const std::string& get_kernel_name() const { - return kernel_name; - } - const std::string& get_operator_name() const { - return operator_name; - } - float get_avg_time_us() const; - size_t get_num_iterations() const { - return iter_timings.size(); - } - const std::vector& get_iter_timings() const { - return iter_timings; - } - CorrectnessStatus get_correctness_status() const { - return correctness_status_; - } - - // Setters - void set_kernel_name(const std::string& name) { - kernel_name = name; - } - void set_operator_name(const std::string& name) { - operator_name = name; - } - void set_correctness_status(CorrectnessStatus status) { - correctness_status_ = status; - } - - // Statistics - float get_min_time_us() const; - float get_max_time_us() const; - float get_std_dev_us() const; - - // Clear all timings - void clear_timings() { - iter_timings.clear(); - } - - // Print progress for this benchmark result - void print_summary( - int case_number, - const std::string& size_info, - float total_gflops) const; - - private: - std::string kernel_name; - std::string operator_name; - std::vector - iter_timings; // Individual iteration timings in microseconds - std::vector shader_timings_; // Per-shader timing data - CorrectnessStatus correctness_status_; -}; - -// Test result collection and processing -class TestResult { - public: - TestResult() : gflops_(0.0f), correctness_passed_(true) {} - explicit TestResult(const std::string& operation_name) - : operation_name_(operation_name), - gflops_(0.0f), - correctness_passed_(true) {} - - // Add a benchmark result - void add_result(const BenchmarkResult& result); - void add_result(BenchmarkResult&& result); - - // Getters - const std::string& get_operation_name() const { - return operation_name_; - } - float get_gflops() const { - return gflops_; - } - bool get_correctness_passed() const { - return correctness_passed_; - } - size_t size() const { - return results_.size(); - } - bool empty() const { - return results_.empty(); - } - - // Setters - void set_gflops(float gflops_val) { - gflops_ = gflops_val; - } - void set_correctness_passed(bool passed) { - correctness_passed_ = passed; - } - - // Access results - const BenchmarkResult& operator[](size_t index) const { - return results_[index]; - } - BenchmarkResult& operator[](size_t index) { - return results_[index]; - } - const std::vector& get_results() const { - return results_; - } - - // Iterator support - std::vector::iterator begin() { - return results_.begin(); - } - std::vector::iterator end() { - return results_.end(); - } - std::vector::const_iterator begin() const { - return results_.begin(); - } - std::vector::const_iterator end() const { - return results_.end(); - } - - // Processing and analysis - void print_summary() const; - void print_detailed_results() const; - void print_statistics() const; - void print_brief_summary() const; - - // Get aggregate statistics - float get_total_avg_time_us() const; - float get_total_gflops() const; - size_t get_passed_count() const; - size_t get_failed_count() const; - - // Find best/worst performing results - const BenchmarkResult* get_fastest_result() const; - const BenchmarkResult* get_slowest_result() const; - const BenchmarkResult* get_highest_gflops_result() const; - - // Clear all results - void clear() { - results_.clear(); - } - - // Set operation name - void set_operation_name(const std::string& name) { - operation_name_ = name; - } - - private: - std::string operation_name_; - std::vector results_; - float gflops_; - bool correctness_passed_; -}; - -// -// Print utilities -// - -void print_performance_header(); -void print_separator(); - -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/framework/runner.cpp b/backends/vulkan/test/ops/benchmark/framework/runner.cpp deleted file mode 100644 index 6105522b79b..00000000000 --- a/backends/vulkan/test/ops/benchmark/framework/runner.cpp +++ /dev/null @@ -1,757 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include "runner.h" -#include "config.h" - -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { - -namespace { - -// Upper bound on the auto-sized chained_dispatches factor. Caps the factor -// when an op is fast enough that target_us / probe_us would otherwise -// exceed this; past this point, stacking more dispatches doesn't materially -// improve measurement precision. -constexpr int kMaxChainedDispatches = 200; - -// Floor for probe_us to avoid division-by-zero / a runaway chained_dispatches -// factor when an op finishes faster than the wall clock can resolve. -constexpr float kMinProbeTimeUs = 1.0f; - -} // namespace - -// Benchmark graphs are immutable after input upload, so the operator nodes can -// be recorded repeatedly. Staging uploads/downloads are encoded once: -// repeating them would redo host-visible copies on every repetition and bias -// per-invocation timings. -RepeatedGraphExecutor::RepeatedGraphExecutor( - ComputeGraph& graph, - int repetitions, - OpNodeRange op_nodes) - : graph_(graph) { - VK_CHECK_COND(repetitions > 0); - VK_CHECK_COND(op_nodes.begin <= op_nodes.end); - VK_CHECK_COND(op_nodes.end <= graph_.execute_nodes().size()); - - api::Context* const context = graph_.context(); - context->flush(); - context->set_cmd(/*reusable=*/true); - context->cmd_reset_querypool(); - - for (size_t i = 0; i < op_nodes.begin; ++i) { - graph_.execute_nodes()[i]->encode(&graph_); - } - for (int i = 0; i < repetitions; ++i) { - for (size_t j = op_nodes.begin; j < op_nodes.end; ++j) { - graph_.execute_nodes()[j]->encode(&graph_); - } - } - for (size_t i = op_nodes.end; i < graph_.execute_nodes().size(); ++i) { - graph_.execute_nodes()[i]->encode(&graph_); - } - - command_ = - std::make_unique(std::move(context->extract_cmd())); -} - -void RepeatedGraphExecutor::execute() { - api::Context* const context = graph_.context(); - command_->end(); - - // Intentionally bypasses Context::submit_cmd_to_gpu(): its submit-count - // bookkeeping and threshold-split path only serve submit_compute_job - // recording, which benchmarks don't use. - vkapi::VulkanFence fence = context->fences().get_fence(); - context->adapter_ptr()->submit_cmd( - context->queue(), - command_->get_submit_handle(/*final_use=*/false), - fence.get_submit_handle()); - fence.wait(); - context->fences().return_fence(fence); -} - -// Helper function to collect GPU timing from querypool -float collect_gpu_timing_us( - ComputeGraph& graph, - const std::vector& shader_filter) { - graph.context()->querypool().extract_results(); - const auto results = graph.context()->querypool().get_shader_timestamp_data(); - if (!results.empty()) { - // Sum durations of all shaders that don't match any pattern in - // shader_filter - float total_duration_us = 0.0f; - for (const auto& shader_result : results) { - bool filtered = false; - // Check if this shader matches any filter pattern - for (const auto& filter_pattern : shader_filter) { - if (shader_result.kernel_name.find(filter_pattern) != - std::string::npos) { - filtered = true; - break; - } - } - - if (!filtered) { - // Calculate duration from start and end times, convert from ns to μs - uint64_t duration_ns = - shader_result.end_time_ns - shader_result.start_time_ns; - total_duration_us += static_cast(duration_ns) / 1000.0f; - } - } - return total_duration_us; - } - return 0.0f; -} - -// Helper function to collect per-shader GPU timing from querypool -// Returns a map of shader_name -> timing_us for non-filtered shaders -std::unordered_map collect_per_shader_timing_us( - ComputeGraph& graph, - const std::vector& shader_filter) { - std::unordered_map shader_timings; - - graph.context()->querypool().extract_results(); - const auto results = graph.context()->querypool().get_shader_timestamp_data(); - for (const auto& shader_result : results) { - bool filtered = false; - // Check if this shader matches any filter pattern - for (const auto& filter_pattern : shader_filter) { - if (shader_result.kernel_name.find(filter_pattern) != std::string::npos) { - filtered = true; - break; - } - } - - if (!filtered) { - // Calculate duration from start and end times, convert from ns to μs - uint64_t duration_ns = - shader_result.end_time_ns - shader_result.start_time_ns; - float duration_us = static_cast(duration_ns) / 1000.0f; - // Accumulate timing for shaders with the same name - shader_timings[shader_result.kernel_name] += duration_us; - } - } - return shader_timings; -} - -// Default FLOP calculation function (assumes 1 FLOP per element) -int64_t default_flop_calculator(const TestCase& test_case) { - // Calculate total elements from the first input tensor - int64_t total_elements = 1; - if (!test_case.empty() && test_case.num_inputs() > 0 && - test_case.inputs()[0].is_tensor()) { - const auto& sizes = test_case.inputs()[0].get_tensor_sizes(); - for (int64_t size : sizes) { - total_elements *= size; - } - } - - // Assume 1 FLOP per element for basic operations - return total_elements; -} - -BenchmarkGraph setup_compute_graph( - TestCase& test_case, - const std::string& op_name, - int op_invocations_per_execute) { - GraphConfig config; - config.enable_querypool = use_gpu_timestamps(); - // Pool sizing takes max(execute, prepack) * factor, so scaling the factor - // also over-reserves the prepack side (encoded once). Accepted: precise - // execute-only sizing would need runtime changes. - config.descriptor_pool_safety_factor *= - std::max(1, op_invocations_per_execute); - // Default-on (opt-out via TestCase::set_force_resize(false)): force every - // DynamicDispatchNode to run its resize function when execute_test_case - // runs propagate_resize() after prepack, exercising the op's resize formula - // even when input shapes are unchanged. - config.force_resize = test_case.get_force_resize(); - config.force_narrow_int4_tile = test_case.get_force_narrow_int4_tile(); - auto graph = std::make_unique(config); - - std::vector input_values; - - // Process input ValueSpecs - for (size_t i = 0; i < test_case.num_inputs(); ++i) { - const ValueSpec& input_spec = test_case.inputs()[i]; - - if (input_spec.is_none()) { - input_values.push_back(graph->add_none()); - } else if (input_spec.is_float()) { - ValueRef input_value = - graph->add_scalar(static_cast(input_spec.get_float_value())); - input_values.push_back(input_value); - } else if (input_spec.is_int()) { - ValueRef input_value = - graph->add_scalar(static_cast(input_spec.get_int_value())); - input_values.push_back(input_value); - } else if (input_spec.is_bool()) { - ValueRef input_value = graph->add_scalar(input_spec.get_bool_value()); - input_values.push_back(input_value); - } else if (input_spec.is_int_list()) { - // Convert int32_t list to int64_t list for ComputeGraph - const auto& int32_list = input_spec.get_int_list(); - std::vector int64_list; - int64_list.reserve(int32_list.size()); - for (int32_t val : int32_list) { - int64_list.push_back(static_cast(val)); - } - ValueRef input_value = graph->add_scalar_list(std::move(int64_list)); - input_values.push_back(input_value); - } else if (input_spec.is_string()) { - std::string str_copy = input_spec.get_string_value(); - ValueRef input_value = graph->add_string(std::move(str_copy)); - input_values.push_back(input_value); - } else if (input_spec.is_constant()) { - ValueRef input_value = graph->add_tensorref( - input_spec.get_tensor_sizes(), - input_spec.dtype, - input_spec.get_data_ptr()); - input_values.push_back(input_value); - } else { - IOValueRef input_io = graph->add_input_tensor( - input_spec.get_tensor_sizes(), - input_spec.dtype, - input_spec.storage_type, - input_spec.memory_layout); - input_values.push_back(input_io.value); - } - } - - std::vector output_values; - - // Process output ValueSpecs - for (size_t i = 0; i < test_case.num_outputs(); ++i) { - const ValueSpec& output_spec = test_case.outputs()[i]; - - if (!output_spec.is_tensor()) { - throw std::runtime_error("All output specifications must be tensors"); - } - - // Create output tensor - ValueRef output_value = graph->add_tensor( - output_spec.get_tensor_sizes(), - output_spec.dtype, - output_spec.storage_type, - output_spec.memory_layout); - - output_values.push_back(output_value); - } - - // Get the operator function and call it - auto opFn = VK_GET_OP_FN(op_name); - - // Create arguments vector for the operator function - std::vector op_args = input_values; - op_args.insert(op_args.end(), output_values.begin(), output_values.end()); - - // Nodes added before the op are staging uploads; nodes added after are - // staging downloads. Only the op's own nodes are repeated by benchmarks. - const size_t op_begin = graph->execute_nodes().size(); - opFn(*graph, op_args); - const size_t op_end = graph->execute_nodes().size(); - - for (size_t i = 0; i < output_values.size(); ++i) { - graph->set_output_value(output_values[i]); - } - return {std::move(graph), {op_begin, op_end}}; -} - -// Test execution utilities -BenchmarkResult execute_test_case( - TestCase& test_case, - int warmup_runs, - int benchmark_runs, - int chained_dispatches, - bool write_outputs) { - BenchmarkResult result( - test_case.name().empty() ? "unnamed_test_case" : test_case.name()); - - // Initialize querypool if using GPU timestamps - if (use_gpu_timestamps()) { - api::context()->initialize_querypool(); - } - - // Build the operator once. Benchmark repetition is encoded separately so - // persistent graph allocations are not duplicated. - BenchmarkGraph benchmark = setup_compute_graph( - test_case, test_case.operator_name(), chained_dispatches); - ComputeGraph& graph = *benchmark.graph; - - // Prepare the graph - graph.prepare(); - graph.prepack(); - - // Run resize functions once so force_resize exercises resize formulas even - // though the record/replay path below never calls propagate_resize(). - graph.propagate_resize(); - - // Copy input data into the graph's staging buffers - size_t graph_input_idx = 0; - for (size_t i = 0; i < test_case.num_inputs(); ++i) { - const ValueSpec& input_spec = test_case.inputs()[i]; - - // Only non-constant tensor inputs correspond to graph.inputs() entries - bool is_graph_input = input_spec.is_tensor() && !input_spec.is_constant() && - !input_spec.is_none(); - if (!is_graph_input) { - continue; - } - - if (graph_input_idx < graph.inputs().size()) { - const auto& input_ref = graph.inputs()[graph_input_idx]; - - // Get the appropriate data based on dtype - const void* data_ptr = nullptr; - size_t data_numel = input_spec.numel(); - - // NOLINTNEXTLINE(clang-diagnostic-switch-enum) - switch (input_spec.dtype) { - case vkapi::kFloat: - data_ptr = input_spec.get_float_data().data(); - break; - case vkapi::kHalf: - data_ptr = input_spec.get_half_data().data(); - break; - case vkapi::kInt: - data_ptr = input_spec.get_int32_data().data(); - break; - case vkapi::kChar: - data_ptr = input_spec.get_int8_data().data(); - break; - case vkapi::kByte: - data_ptr = input_spec.get_uint8_data().data(); - break; - default: - throw std::runtime_error("Unsupported data type for input tensor"); - } - - // Copy data into staging buffer - graph.maybe_cast_and_copy_into_staging( - input_ref.staging, data_ptr, data_numel, input_spec.dtype); - } - ++graph_input_idx; - } - - RepeatedGraphExecutor graph_executor( - graph, chained_dispatches, benchmark.op_nodes); - - // Warmup runs - for (int run = 0; run < warmup_runs; ++run) { - graph_executor.execute(); - } - - // Benchmark runs - collect individual iteration timings - float total_cpu_time_us = 0.0f; - float total_gpu_time_us = 0.0f; - - const float chained_dispatches_f = static_cast(chained_dispatches); - - for (int run = 0; run < benchmark_runs; ++run) { - // Measure CPU time for each execute() call - auto cpu_start = std::chrono::high_resolution_clock::now(); - graph_executor.execute(); - auto cpu_end = std::chrono::high_resolution_clock::now(); - - auto cpu_duration = std::chrono::duration_cast( - cpu_end - cpu_start); - // Each execute() ran chained_dispatches op invocations; report - // per-invocation time. - float cpu_time_us = - static_cast(cpu_duration.count()) / chained_dispatches_f; - total_cpu_time_us += cpu_time_us; - - // Collect per-shader GPU timing - get raw shader results to preserve - // metadata - graph.context()->querypool().extract_results(); - const auto shader_results = - graph.context()->querypool().get_shader_timestamp_data(); - - // Calculate total GPU time from per-shader timings - float gpu_time_us = 0.0f; - for (const auto& shader_result : shader_results) { - // Check if this shader matches any filter pattern - bool filtered = false; - for (const auto& filter_pattern : test_case.get_shader_filter()) { - if (shader_result.kernel_name.find(filter_pattern) != - std::string::npos) { - filtered = true; - break; - } - } - - if (!filtered) { - uint64_t duration_ns = - shader_result.end_time_ns - shader_result.start_time_ns; - float duration_us = static_cast(duration_ns) / 1000.0f; - gpu_time_us += duration_us; - // Per-shader sample is already one querypool entry per dispatch (at - // chained_dispatches>1 we get that many entries per shader), so do NOT - // divide here — the aggregator (get_avg_time_us) averages across them - // naturally. - result.add_shader_timing( - shader_result.kernel_name, - duration_us, - shader_result.metadata.gwg, - shader_result.metadata.lwg); - } - } - // gpu_time_us aggregates chained_dispatches worth of shader runs; divide - // it down to per-invocation time. - gpu_time_us /= chained_dispatches_f; - total_gpu_time_us += gpu_time_us; - - // Add the appropriate timing based on the flag - float iter_time_us = use_gpu_timestamps() ? gpu_time_us : cpu_time_us; - result.add_iter_timing(iter_time_us); - } - - // Calculate averages for display - float avg_cpu_time_us = total_cpu_time_us / benchmark_runs; - float avg_gpu_time_us = total_gpu_time_us / benchmark_runs; - - // Print both timings if latency printing is enabled - if (print_latencies()) { - if (use_gpu_timestamps()) { - graph.context()->querypool().print_results(); - } - std::cout << " CPU timing: " << std::fixed << std::setprecision(3) - << avg_cpu_time_us << " μs" << std::endl; - std::cout << " GPU timing: " << std::fixed << std::setprecision(3) - << avg_gpu_time_us << " μs" << std::endl; - std::cout << " Using " << (use_gpu_timestamps() ? "GPU" : "CPU") - << " timing for result" << std::endl; - } - - // Copy output data from the graph's staging buffers. The benchmarking path - // skips this work since it doesn't need correctness data — the probe pass - // has already populated test_case.outputs() at chained_dispatches=1. - if (write_outputs) { - for (size_t i = 0; i < test_case.num_outputs(); ++i) { - ValueSpec& output_spec = test_case.outputs()[i]; - - if (output_spec.is_tensor() && i < graph.outputs().size()) { - const auto& output_ref = graph.outputs()[i]; - - // Ensure output data vector is properly sized - size_t data_numel = output_spec.numel(); - output_spec.resize_data(data_numel); - - // Get mutable data pointer for the output - void* data_ptr = output_spec.get_mutable_data_ptr(); - - if (data_ptr != nullptr) { - // Copy data from staging buffer to output spec - graph.maybe_cast_and_copy_from_staging( - output_ref.staging, data_ptr, data_numel, output_spec.dtype); - } - - // Print output tensor data if output printing is enabled - if (print_output()) { - std::string output_name = "Output[" + std::to_string(i) + "]"; - print_valuespec_data(output_spec, output_name); - } - } - } - } - - return result; -} - -TestResult execute_test_cases( - std::vector test_cases, - const FlopCalculatorFunc& flop_calculator, - const std::string& operation_name, - int warmup_runs, - int benchmark_runs, - const ReferenceComputeFunc& reference_compute_func) { - TestResult results(operation_name); - - std::cout << "Executing " << test_cases.size() << " test cases for " - << operation_name << std::endl; - print_separator(); - - // Group test cases by ReferenceKey for caching reference computations - // Use a vector to preserve the order in which groups first appear - std::vector group_order; - std::unordered_map, ReferenceKeyHash> - groups; - for (size_t i = 0; i < test_cases.size(); ++i) { - ReferenceKey key = ReferenceKey::from_test_case(test_cases[i]); - if (groups.find(key) == groups.end()) { - group_order.push_back(key); - } - groups[key].push_back(i); - } - - bool any_correctness_failed = false; - float total_gflops = 0.0f; - size_t test_case_counter = 0; - - // Process each group: generate data, compute reference, execute, and print - // Iterate in the order groups first appeared in test_cases - for (const auto& key : group_order) { - const auto& indices = groups[key]; - if (indices.empty()) { - continue; - } - - // Get first test case as the "prototype" - size_t prototype_idx = indices[0]; - TestCase& prototype = test_cases[prototype_idx]; - - // Generate data for prototype with deterministic seed based on key - int group_seed = - static_cast(std::hash{}(key.key_string) % 10000); - for (auto& input : prototype.inputs()) { - input.ensure_data_generated(group_seed++); - } - - // Compute reference once for prototype - bool ref_computed = false; - if (reference_compute_func) { - try { - reference_compute_func(prototype); - ref_computed = true; - } catch (const std::invalid_argument&) { - // Reference computation skipped for this group - } - } - - // Copy data and reference to other test cases in group - for (size_t i = 1; i < indices.size(); ++i) { - size_t tc_idx = indices[i]; - TestCase& tc = test_cases[tc_idx]; - - // Copy input data from prototype - for (size_t j = 0; - j < tc.inputs().size() && j < prototype.inputs().size(); - ++j) { - auto& dest = tc.inputs()[j]; - const auto& src = prototype.inputs()[j]; - if (dest.is_tensor() && src.is_tensor() && dest.sizes == src.sizes && - dest.dtype == src.dtype) { - dest.share_data_from(src); - } - } - - // Copy reference output data if available - if (ref_computed) { - for (size_t j = 0; - j < tc.outputs().size() && j < prototype.outputs().size(); - ++j) { - const auto& src = prototype.outputs()[j]; - auto& dest = tc.outputs()[j]; - if (dest.is_tensor() && src.is_tensor() && dest.sizes == src.sizes && - dest.dtype == src.dtype) { - dest.share_reference_from(src); - } - } - } - } - - // Execute and print results for all test cases in this group - for (size_t tc_idx : indices) { - TestCase& test_case = test_cases[tc_idx]; - ++test_case_counter; - - // Execute single test case - BenchmarkResult result; - bool shader_not_supported = false; - try { - // Always run a probe pass at chained_dispatches=1 with - // write_outputs=true. This populates test_case.outputs() for the - // downstream correctness check with a clean single-dispatch result, - // and also gives us probe_us for adaptive sizing of the measurement - // run's chained_dispatches. - // - // probe_then_scale (Google Benchmark style): size chained_dispatches - // so each measurement execute() takes ~target_us. Tiny ops get a - // large factor (driving GPU governor escalation); heavy ops get a - // small factor (bounded wall time). Caveat: on Adreno 740 the probe - // runs at the DCVS-pinned clock (~220 MHz), so probe_us is inflated - // and the computed factor comes out under-sized for boost clock — but - // the default target is generous enough that an under-sized factor - // still drives sustained activity. - BenchmarkResult probe_result = execute_test_case( - test_case, - /*warmup_runs=*/1, - /*benchmark_runs=*/1, - /*chained_dispatches=*/1, - /*write_outputs=*/true); - float probe_us = probe_result.get_avg_time_us(); - if (probe_us < kMinProbeTimeUs) { - probe_us = kMinProbeTimeUs; - } - - int chained_dispatches; - const int manual_n = test_case.get_op_invocations_per_execute(); - if (manual_n > 0) { - chained_dispatches = manual_n; - } else { - const int target_us = test_case.get_target_execute_time_us(); - chained_dispatches = - std::max(1, static_cast(target_us / probe_us)); - chained_dispatches = - std::min(chained_dispatches, kMaxChainedDispatches); - } - if (debugging()) { - std::cout << "[probe] " << test_case.name() - << ": probe_us=" << probe_us - << ", chained_dispatches=" << chained_dispatches - << std::endl; - } - - // Measurement pass: chained_dispatches stacking, skip output copy - // since the probe already wrote the correctness data. - result = execute_test_case( - test_case, - warmup_runs, - benchmark_runs, - chained_dispatches, - /*write_outputs=*/false); - result.set_operator_name(test_case.operator_name()); - } catch (const vkcompute::vkapi::ShaderNotSupportedError&) { - result = BenchmarkResult( - test_case.name().empty() ? "unnamed_test_case" : test_case.name(), - test_case.operator_name()); - shader_not_supported = true; - } - - // Determine if this test case passed (has valid timing data) - bool vulkan_execute_succeeded = - result.get_num_iterations() > 0 && result.get_avg_time_us() > 0.0f; - - if (shader_not_supported) { - result.set_correctness_status(CorrectnessStatus::SKIPPED); - } else if (!vulkan_execute_succeeded) { - result.set_correctness_status(CorrectnessStatus::FAILED); - } else if (!ref_computed) { - result.set_correctness_status(CorrectnessStatus::SKIPPED); - } else { - // Reference function provided and succeeded - validate outputs - bool correctness_passed = true; - - for (size_t output_idx = 0; output_idx < test_case.num_outputs(); - ++output_idx) { - const ValueSpec& output_spec = test_case.outputs()[output_idx]; - - if (!output_spec.validate_against_reference( - test_case.get_abs_tolerance(), - test_case.get_rel_tolerance())) { - correctness_passed = false; - std::cout << " Correctness validation FAILED for test " - << result.get_kernel_name() << std::endl; - print_valuespec_data(output_spec, "vulkan output"); - print_valuespec_data(output_spec, "ref output", true); - } - } - - if (correctness_passed) { - result.set_correctness_status(CorrectnessStatus::PASSED); - } else { - any_correctness_failed = true; - result.set_correctness_status(CorrectnessStatus::FAILED); - } - } - - // Calculate GFLOPS for this test case using the provided FLOP calculator - float case_gflops = 0.0f; - if (vulkan_execute_succeeded) { - // Use the provided FLOP calculator to get total FLOPs for this test - // case - int64_t total_flops = flop_calculator(test_case); - float flops = static_cast(total_flops); - float avg_time_us = result.get_avg_time_us(); - if (avg_time_us > 0.0f && total_flops > 0) { - case_gflops = (flops / 1e9f) / (avg_time_us / 1e6f); - } - - total_gflops += case_gflops; - } else { - case_gflops = -1.0f; // Indicate failure - } - - // Calculate tensor info for display - std::string size_info = "["; - if (!test_case.empty() && test_case.num_inputs() > 0 && - test_case.inputs()[0].is_tensor()) { - const auto& sizes = test_case.inputs()[0].get_tensor_sizes(); - for (size_t j = 0; j < sizes.size(); ++j) { - size_info += std::to_string(sizes[j]); - if (j < sizes.size() - 1) { - size_info += "x"; - } - } - } - size_info += "]"; - - // Print progress using the BenchmarkResult member function - result.print_summary( - utils::safe_downcast(test_case_counter), size_info, case_gflops); - - // Add result to collection - results.add_result(std::move(result)); - - test_case.clear(); - } - } - - // Set the overall results on the TestResult - results.set_correctness_passed(!any_correctness_failed); - results.set_gflops(total_gflops); - - print_separator(); - std::cout << "Completed " << results.size() << " test cases" << std::endl; - - // Hard-fail if any correctness check failed. The per-case loop above keeps - // running after a mismatch (so the full pass/fail matrix and per-16x16-tile - // mismatch maps are printed for debugging) rather than aborting on the first - // failure; throwing here preserves the original abort-on-mismatch behavior. - if (any_correctness_failed) { - throw std::runtime_error("Correctness validation failed"); - } - - return results; -} - -// Convenience overload that uses the default FLOP calculator -TestResult execute_test_cases( - std::vector test_cases, - const std::string& operation_name, - int warmup_runs, - int benchmark_runs, - const ReferenceComputeFunc& reference_compute_func) { - return execute_test_cases( - std::move(test_cases), - default_flop_calculator, - operation_name, - warmup_runs, - benchmark_runs, - reference_compute_func); -} - -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/framework/runner.h b/backends/vulkan/test/ops/benchmark/framework/runner.h deleted file mode 100644 index 56a7462b3c6..00000000000 --- a/backends/vulkan/test/ops/benchmark/framework/runner.h +++ /dev/null @@ -1,123 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#pragma once - -#include - -#include "results.h" -#include "test_case.h" - -#include -#include -#include -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { - -// -// Test case execution -// - -using FlopCalculatorFunc = std::function; - -// Default FLOP calculation function (assumes 1 FLOP per element) -int64_t default_flop_calculator(const TestCase& test_case); - -using ReferenceComputeFunc = std::function; - -// Half-open index range of the operator's own dispatch nodes within a -// benchmark graph's execute_nodes(). Staging upload nodes precede it, staging -// download nodes follow it. -struct OpNodeRange { - size_t begin = 0; - size_t end = 0; -}; - -// A benchmark graph plus the location of its repeatable operator nodes. The -// graph is heap-held: ComputeGraph owns its Context and must never be moved -// (a moved-from graph's destructor dereferences a null context). -struct BenchmarkGraph { - std::unique_ptr graph; - OpNodeRange op_nodes; -}; - -// Benchmark-only executor that records a graph's execute nodes into a single -// reusable command buffer, then replays it on every execute(). Staging uploads -// are encoded once, operator nodes N times, staging downloads once, so each -// iteration performs the same work as the old stacked-nodes layout (1 upload + -// N ops + 1 download) and the per-invocation divisor is unchanged. Production -// ComputeGraph::execute() behavior is unchanged. -// -// Notes for interpreting benchmark numbers: -// - Submit granularity differs from stacking N distinct nodes: all encodings -// live in one command buffer with one submit per iteration (the old path -// could split across command buffers at the node-count threshold), so -// per-dispatch times may shift systematically against older data. -// - Resize functions run once via propagate_resize() in execute_test_case -// before recording; replay itself never re-triggers resize. -// - Repeated encodings share one node/dispatch id, so per-repetition -// querypool attribution is unavailable (aggregation keys on kernel name). -class RepeatedGraphExecutor final { - public: - RepeatedGraphExecutor( - ComputeGraph& graph, - int repetitions, - OpNodeRange op_nodes); - void execute(); - - private: - ComputeGraph& graph_; - std::unique_ptr command_; -}; - -// Runs a measurement at the given chained_dispatches factor. The operator is -// built once, then its execute nodes are encoded that many times into a -// benchmark-only reusable command buffer. The probe-then-scale orchestration -// lives in execute_test_cases(). -// -// write_outputs controls whether the graph's staging output buffers are copied -// back into test_case.outputs() at the end of the run. The probe path needs -// write_outputs=true so the correctness check has a clean single-dispatch -// reference. The benchmarking path passes write_outputs=false to avoid the -// per-iter GPU->CPU copy cost. -BenchmarkResult execute_test_case( - TestCase& test_case, - int warmup_runs = 1, - int benchmark_runs = 1, - int chained_dispatches = 1, - bool write_outputs = true); - -TestResult execute_test_cases( - std::vector test_cases, - const FlopCalculatorFunc& flop_calculator, - const std::string& operation_name = "Operation", - int warmup_runs = 1, - int benchmark_runs = 1, - const ReferenceComputeFunc& reference_compute_func = nullptr); - -TestResult execute_test_cases( - std::vector test_cases, - const std::string& operation_name = "Operation", - int warmup_runs = 1, - int benchmark_runs = 1, - const ReferenceComputeFunc& reference_compute_func = nullptr); - -// Setup compute graph based on TestCase and operation name. The op function is -// invoked once. op_invocations_per_execute is used only to reserve enough -// descriptor capacity for benchmark-only repeated command encoding. Returns -// the graph plus the range of the operator's own nodes for repeated encoding. -BenchmarkGraph setup_compute_graph( - TestCase& test_case, - const std::string& op_name, - int op_invocations_per_execute = 1); - -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/framework/test_case.cpp b/backends/vulkan/test/ops/benchmark/framework/test_case.cpp deleted file mode 100644 index 550bd10c136..00000000000 --- a/backends/vulkan/test/ops/benchmark/framework/test_case.cpp +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include "test_case.h" - -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { - -// ReferenceKey implementation -ReferenceKey ReferenceKey::from_test_case(const TestCase& tc) { - std::ostringstream oss; - - // Serialize inputs that affect reference computation - // Skip: storage_type, memory_layout, string values (like impl_selector) - for (size_t i = 0; i < tc.inputs().size(); ++i) { - const ValueSpec& input = tc.inputs()[i]; - oss << "i" << i << ":"; - - if (input.is_tensor()) { - // For tensors: sizes, dtype, data_gen_type, is_constant - oss << "T["; - for (size_t j = 0; j < input.sizes.size(); ++j) { - if (j > 0) { - oss << ","; - } - oss << input.sizes[j]; - } - oss << "]d" << static_cast(input.dtype); - oss << "g" << static_cast(input.data_gen_type); - oss << "c" << (input.is_constant() ? 1 : 0); - oss << "n" << (input.is_none() ? 1 : 0); - } else if (input.is_int()) { - oss << "I" << input.get_int_value(); - } else if (input.is_float()) { - oss << "F" << input.get_float_value(); - } else if (input.is_bool()) { - oss << "B" << (input.get_bool_value() ? 1 : 0); - } else if (input.is_int_list()) { - oss << "L["; - const auto& list = input.get_int_list(); - for (size_t j = 0; j < list.size(); ++j) { - if (j > 0) { - oss << ","; - } - oss << list[j]; - } - oss << "]"; - } - // Skip string inputs (like impl_selector) as they don't affect reference - oss << ";"; - } - - // Also include output shapes for completeness - for (size_t i = 0; i < tc.outputs().size(); ++i) { - const ValueSpec& output = tc.outputs()[i]; - oss << "o" << i << ":["; - for (size_t j = 0; j < output.sizes.size(); ++j) { - if (j > 0) { - oss << ","; - } - oss << output.sizes[j]; - } - oss << "]d" << static_cast(output.dtype) << ";"; - } - - ReferenceKey key; - key.key_string = oss.str(); - return key; -} - -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/framework/test_case.h b/backends/vulkan/test/ops/benchmark/framework/test_case.h deleted file mode 100644 index 5edd5838ffc..00000000000 --- a/backends/vulkan/test/ops/benchmark/framework/test_case.h +++ /dev/null @@ -1,236 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#pragma once - -#include "value_spec.h" - -#include -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { - -// -// ReferenceKey for caching reference computations -// - -// Captures the identity of input conditions for test case grouping. -// Test cases with the same ReferenceKey should produce identical reference -// outputs, so reference computation can be cached and reused. -struct ReferenceKey { - std::string key_string; - - static ReferenceKey from_test_case(const class TestCase& tc); - - bool operator==(const ReferenceKey& other) const { - return key_string == other.key_string; - } -}; - -struct ReferenceKeyHash { - size_t operator()(const ReferenceKey& k) const { - return std::hash{}(k.key_string); - } -}; - -// -// Shader filter defaults -// - -// Default shader filter: excludes layout conversions and quantization overhead -// This is used for tests where quantize/dequantize are overhead operations -inline const std::vector kDefaultShaderFilter = { - "nchw_to", - "to_nchw", - "quantize_and_pack_4w4c", - "unpack_4w4c_and_dequantize"}; - -// Layout-only filter: only excludes layout conversions -// Use this for tests where quantize/dequantize ARE the operations being tested -inline const std::vector kLayoutOnlyShaderFilter = { - "nchw_to", - "to_nchw"}; - -// -// TestCase -// - -// Default per-execute() wall-clock target used by the probe-then-scale logic -// in execute_test_cases(). Picked generously enough that even an under-sized -// chained_dispatches factor (the probe runs at governor-pinned clock and -// underestimates the boost-clock latency) still drives sustained GPU -// activity during measurement. -constexpr int kDefaultTargetExecuteTimeUs = 100000; - -class TestCase { - public: - TestCase() - : abs_tolerance_(2e-3f), - rel_tolerance_(1e-3f), - shader_filter_(kDefaultShaderFilter) {} - explicit TestCase(const std::string& name) - : name_(name), - abs_tolerance_(2e-3f), - rel_tolerance_(1e-3f), - shader_filter_(kDefaultShaderFilter) {} - - void set_name(const std::string& name) { - name_ = name; - } - const std::string& name() const { - return name_; - } - - void set_operator_name(const std::string& op_name) { - operator_name_ = op_name; - } - const std::string& operator_name() const { - return operator_name_; - } - - // Tolerance settings - void set_abs_tolerance(float abs_tolerance) { - abs_tolerance_ = abs_tolerance; - } - float get_abs_tolerance() const { - return abs_tolerance_; - } - - void set_rel_tolerance(float rel_tolerance) { - rel_tolerance_ = rel_tolerance; - } - float get_rel_tolerance() const { - return rel_tolerance_; - } - - // Shader filter settings - list of shader name patterns to exclude from - // timing - void set_shader_filter(const std::vector& filter) { - shader_filter_ = filter; - } - const std::vector& get_shader_filter() const { - return shader_filter_; - } - - // Manual override for the number of chained dispatches per measurement - // iteration (a.k.a. chained_dispatches). If > 0, the framework uses this - // directly and skips the probe phase. 0 (the default) means adaptive - // (probe-then-scale). - void set_op_invocations_per_execute(int n) { - op_invocations_per_execute_ = n; - } - int get_op_invocations_per_execute() const { - return op_invocations_per_execute_; - } - - // Target single-execute duration in microseconds. Used only when the - // manual chained_dispatches override is not set. Default - // kDefaultTargetExecuteTimeUs, picked generously enough to mitigate Adreno - // DCVS governor pinning during the probe. - void set_target_execute_time_us(int us) { - target_execute_time_us_ = us; - } - int get_target_execute_time_us() const { - return target_execute_time_us_; - } - - // When true, the ComputeGraph built for this test case sets - // GraphConfig::force_resize, so every DynamicDispatchNode runs its resize - // function once during measurement setup (execute_test_case runs - // propagate_resize() after prepack) even when no input shape changed. - // Because the output is already allocated at the swept shape, the resize - // must recompute the same shape from the current input — a wrong resize - // formula resizes the output to a mismatched shape and surfaces as a test - // failure. Default true (opt-out): every custom_ops test exercises its - // resize formulas across the swept shapes. Call set_force_resize(false) for - // the rare op whose resize fn is intentionally not shape-preserving under a - // fixed output allocation. - void set_force_resize(bool force_resize) { - force_resize_ = force_resize; - } - bool get_force_resize() const { - return force_resize_; - } - - // When true, the ComputeGraph sets GraphConfig::force_narrow_int4_tile, so - // the int4 linear picks its narrow (2-row) output tile on any GPU. That - // variant is otherwise reachable only on Mali, which no CI runner has. - void set_force_narrow_int4_tile(bool force) { - force_narrow_int4_tile_ = force; - } - bool get_force_narrow_int4_tile() const { - return force_narrow_int4_tile_; - } - - void add_input_spec(const ValueSpec& spec) { - inputs_.push_back(spec); - } - - const std::vector& inputs() const { - return inputs_; - } - - std::vector& inputs() { - return inputs_; - } - - size_t num_inputs() const { - return inputs_.size(); - } - - void add_output_spec(const ValueSpec& spec) { - outputs_.push_back(spec); - } - - const std::vector& outputs() const { - return outputs_; - } - - std::vector& outputs() { - return outputs_; - } - - size_t num_outputs() const { - return outputs_.size(); - } - - bool empty() const { - return inputs_.empty() && outputs_.empty(); - } - void clear() { - inputs_.clear(); - outputs_.clear(); - name_.clear(); - operator_name_.clear(); - abs_tolerance_ = 2e-3f; - rel_tolerance_ = 1e-3f; - shader_filter_ = kDefaultShaderFilter; - op_invocations_per_execute_ = 0; - target_execute_time_us_ = kDefaultTargetExecuteTimeUs; - force_resize_ = true; - force_narrow_int4_tile_ = false; - } - - private: - std::string name_; - std::string operator_name_; - std::vector inputs_; - std::vector outputs_; - float abs_tolerance_; - float rel_tolerance_; - std::vector shader_filter_; - int op_invocations_per_execute_ = 0; // 0 = adaptive - int target_execute_time_us_ = kDefaultTargetExecuteTimeUs; - bool force_resize_ = true; - bool force_narrow_int4_tile_ = false; -}; - -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/framework/utils.h b/backends/vulkan/test/ops/benchmark/framework/utils.h deleted file mode 100644 index 51d5fd1bd8f..00000000000 --- a/backends/vulkan/test/ops/benchmark/framework/utils.h +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#pragma once - -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -#include "config.h" -#include "labels.h" -#include "results.h" -#include "runner.h" -#include "test_case.h" -#include "value_spec.h" -#include "weight_utils.h" diff --git a/backends/vulkan/test/ops/benchmark/framework/value_spec.cpp b/backends/vulkan/test/ops/benchmark/framework/value_spec.cpp deleted file mode 100644 index b9752596f89..00000000000 --- a/backends/vulkan/test/ops/benchmark/framework/value_spec.cpp +++ /dev/null @@ -1,1024 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include "value_spec.h" -#include "config.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { - -int get_seed() { - static int seed = 42; - return seed++; -} - -int get_seed_or_explicit(int explicit_seed) { - if (explicit_seed >= 0) { - return explicit_seed; - } - return get_seed(); -} - -// Forward declarations for data generation utilities -void generate_random_float_data( - std::vector& data, - float min_val = -1.0f, - float max_val = 1.0f, - int explicit_seed = -1); -void generate_random_int_data( - std::vector& data, - int min_val = -10, - int max_val = 10, - int explicit_seed = -1); -void generate_randint_float_data( - std::vector& data, - int min_val = -10, - int max_val = 10, - int explicit_seed = -1); -void generate_randint_half_data( - std::vector& data, - int min_val = -10, - int max_val = 10, - int explicit_seed = -1); -void generate_random_int8_data( - std::vector& data, - int8_t min_val = -10, - int8_t max_val = 10, - int explicit_seed = -1); -void generate_random_uint8_data( - std::vector& data, - uint8_t min_val = 0, - uint8_t max_val = 255, - int explicit_seed = -1); -void generate_random_2xint4_data( - std::vector& data, - int explicit_seed = -1); -void generate_random_2xint4_data( - std::vector& data, - int explicit_seed = -1); -void generate_random_int4_data( - std::vector& data, - int8_t min_val = -8, - int8_t max_val = 7, - int explicit_seed = -1); -void generate_ones_data(std::vector& data); -void generate_zeros_data(std::vector& data); - -// Convert a float32 value to IEEE 754 half-precision (uint16_t) -uint16_t float_to_half(float value) { - uint32_t float_bits; - std::memcpy(&float_bits, &value, sizeof(float)); - uint32_t sign = (float_bits >> 31) & 0x1; - int32_t exponent = static_cast((float_bits >> 23) & 0xFF) - 127; - uint32_t mantissa = float_bits & 0x7FFFFF; - - uint16_t half_val; - if (exponent > 15) { - half_val = static_cast((sign << 15) | 0x7C00); // Inf - } else if (exponent < -14) { - half_val = static_cast(sign << 15); // Zero / subnormal - } else { - half_val = static_cast( - (sign << 15) | (static_cast(exponent + 15) << 10) | - (mantissa >> 13)); - } - return half_val; -} - -// Convert a IEEE 754 half-precision (uint16_t) value to float32 -float half_to_float(uint16_t half_val) { - uint32_t sign = (half_val >> 15) & 0x1; - uint32_t exponent = (half_val >> 10) & 0x1F; - uint32_t mantissa = half_val & 0x3FF; - - float result; - if (exponent == 0) { - result = std::ldexp(static_cast(mantissa), -24); - } else if (exponent == 31) { - result = mantissa ? std::numeric_limits::quiet_NaN() - : std::numeric_limits::infinity(); - } else { - result = std::ldexp( - 1.0f + static_cast(mantissa) / 1024.0f, exponent - 15); - } - if (sign) { - result = -result; - } - return result; -} - -// ValueSpec implementation -void ValueSpec::ensure_unique_data() const { - if (data_.use_count() != 1) { - data_ = std::make_shared(*data_); - } -} - -void ValueSpec::ensure_unique_reference_data() const { - if (reference_data_.use_count() != 1) { - reference_data_ = std::make_shared(*reference_data_); - } -} - -void ValueSpec::generate_tensor_data(int seed) const { - if (spec_type != SpecType::Tensor) { - return; - } - - ensure_unique_data(); - auto& float_data = data_->float_data; - auto& int32_data = data_->int32_data; - auto& half_data = data_->half_data; - auto& int8_data = data_->int8_data; - auto& uint8_data = data_->uint8_data; - - int64_t num_elements = numel(); - - // NOLINTNEXTLINE(clang-diagnostic-switch-enum) - switch (dtype) { - case vkapi::kFloat: { - float_data.resize(num_elements); - if (data_gen_type == DataGenType::RANDOM) { - generate_random_float_data(float_data, -1.0f, 1.0f, seed); - } else if (data_gen_type == DataGenType::RANDOM_SCALES) { - generate_random_float_data(float_data, 0.005, 0.015, seed); - } else if (data_gen_type == DataGenType::RANDINT) { - generate_randint_float_data(float_data, -10, 10, seed); - } else if (data_gen_type == DataGenType::RANDINT8) { - generate_randint_float_data(float_data, -128, 127, seed); - } else if (data_gen_type == DataGenType::RANDINT4) { - generate_randint_float_data(float_data, -8, 7, seed); - } else if (data_gen_type == DataGenType::ONES) { - generate_ones_data(float_data); - } else if (data_gen_type == DataGenType::ZEROS) { - generate_zeros_data(float_data); - } else { - generate_zeros_data(float_data); - } - break; - } - case vkapi::kHalf: { - half_data.resize(num_elements); - if (data_gen_type == DataGenType::RANDOM) { - // Generate random float data first, then convert to IEEE 754 half. - std::vector temp_data(num_elements); - generate_random_float_data(temp_data, -1.0f, 1.0f, seed); - for (size_t i = 0; i < temp_data.size(); ++i) { - half_data[i] = float_to_half(temp_data[i]); - } - } else if (data_gen_type == DataGenType::RANDOM_SCALES) { - // Generate random scales in float, then convert to proper fp16 - std::vector temp_data(num_elements); - generate_random_float_data(temp_data, 0.005f, 0.015f, seed); - for (size_t i = 0; i < temp_data.size(); ++i) { - half_data[i] = float_to_half(temp_data[i]); - } - } else if (data_gen_type == DataGenType::RANDINT) { - generate_randint_half_data(half_data, -10, 10, seed); - } else if (data_gen_type == DataGenType::RANDINT8) { - generate_randint_half_data(half_data, -128, 127, seed); - } else if (data_gen_type == DataGenType::RANDINT4) { - generate_randint_half_data(half_data, -8, 7, seed); - } else if (data_gen_type == DataGenType::ONES) { - std::fill(half_data.begin(), half_data.end(), float_to_half(1.0f)); - } else if (data_gen_type == DataGenType::ZEROS) { - std::fill( - half_data.begin(), - half_data.end(), - static_cast(0)); // 0.0 in half - } else { - std::fill( - half_data.begin(), - half_data.end(), - static_cast(0)); // 0.0 in half - } - break; - } - case vkapi::kInt: { - int32_data.resize(num_elements); - if (data_gen_type == DataGenType::RANDOM) { - generate_random_int_data(int32_data, -10, 10, seed); - } else if (data_gen_type == DataGenType::RANDINT) { - generate_random_int_data( - int32_data, - -10, - 10, - seed); // For int type, RANDINT is same as RANDOM - } else if (data_gen_type == DataGenType::RANDINT8) { - generate_random_int_data(int32_data, -128, 127, seed); - } else if (data_gen_type == DataGenType::RANDINT4) { - generate_random_int_data(int32_data, -8, 7, seed); - } else if (data_gen_type == DataGenType::ONES) { - std::fill(int32_data.begin(), int32_data.end(), 1); - } else if (data_gen_type == DataGenType::ZEROS) { - std::fill(int32_data.begin(), int32_data.end(), 0); - } else { - std::fill(int32_data.begin(), int32_data.end(), 0); - } - break; - } - case vkapi::kChar: { - int8_data.resize(num_elements); - if (data_gen_type == DataGenType::RANDOM) { - generate_random_int8_data(int8_data, -10, 10, seed); - } else if (data_gen_type == DataGenType::RANDINT) { - generate_random_int8_data(int8_data, -10, 10, seed); - } else if (data_gen_type == DataGenType::RANDINT8) { - generate_random_int8_data(int8_data, -128, 127, seed); - } else if (data_gen_type == DataGenType::RANDINT4) { - generate_random_2xint4_data(int8_data, seed); - } else if (data_gen_type == DataGenType::ONES) { - std::fill(int8_data.begin(), int8_data.end(), 1); - } else if (data_gen_type == DataGenType::ONES_INT4) { - int8_t packed_data = (1 << 4) | 1; - std::fill(int8_data.begin(), int8_data.end(), packed_data); - } else if (data_gen_type == DataGenType::ZEROS) { - std::fill(int8_data.begin(), int8_data.end(), 0); - } else { - std::fill(int8_data.begin(), int8_data.end(), 0); - } - break; - } - case vkapi::kByte: { - uint8_data.resize(num_elements); - if (data_gen_type == DataGenType::RANDOM) { - generate_random_uint8_data(uint8_data, 0, 255, seed); - } else if (data_gen_type == DataGenType::RANDINT) { - generate_random_uint8_data(uint8_data, 0, 255, seed); - } else if (data_gen_type == DataGenType::RANDINT8) { - generate_random_uint8_data(uint8_data, 0, 255, seed); - } else if (data_gen_type == DataGenType::RANDINT4) { - generate_random_2xint4_data(uint8_data, seed); - } else if (data_gen_type == DataGenType::ONES) { - std::fill(uint8_data.begin(), uint8_data.end(), 1); - } else if (data_gen_type == DataGenType::ONES_INT4) { - uint8_t packed_data = (9 << 4) | 9; - std::fill(uint8_data.begin(), uint8_data.end(), packed_data); - } else if (data_gen_type == DataGenType::ZEROS) { - std::fill(uint8_data.begin(), uint8_data.end(), 0); - } else { - std::fill(uint8_data.begin(), uint8_data.end(), 0); - } - break; - } - default: - // Default to float - float_data.resize(num_elements); - if (data_gen_type == DataGenType::RANDOM) { - generate_random_float_data(float_data, -1.0f, 1.0f, seed); - } else if (data_gen_type == DataGenType::RANDINT) { - generate_randint_float_data(float_data, -10, 10, seed); - } else if (data_gen_type == DataGenType::ONES) { - generate_ones_data(float_data); - } else if (data_gen_type == DataGenType::ZEROS) { - generate_zeros_data(float_data); - } else { - generate_zeros_data(float_data); - } - break; - } -} - -int64_t ValueSpec::numel() const { - if (spec_type == SpecType::Int || spec_type == SpecType::Float || - spec_type == SpecType::Bool) { - return 1; - } else if (spec_type == SpecType::IntList) { - return sizes.empty() ? 0 : sizes[0]; - } else { // Tensor - int64_t total = 1; - for (int64_t size : sizes) { - total *= size; - } - return total; - } -} - -size_t ValueSpec::nbytes() const { - size_t element_size = 0; - // NOLINTNEXTLINE(clang-diagnostic-switch-enum) - switch (dtype) { - case vkapi::kFloat: - element_size = sizeof(float); - break; - case vkapi::kHalf: - element_size = sizeof(uint16_t); - break; - case vkapi::kInt: - element_size = sizeof(int32_t); - break; - case vkapi::kChar: - element_size = sizeof(int8_t); - break; - case vkapi::kByte: - element_size = sizeof(uint8_t); - break; - default: - element_size = sizeof(float); // Default fallback - break; - } - return numel() * element_size; -} - -std::string ValueSpec::to_string() const { - std::string result = "ValueSpec("; - - switch (spec_type) { - case SpecType::Tensor: - result += "type=Tensor, sizes=["; - break; - case SpecType::IntList: - result += "type=IntList, count="; - result += std::to_string(sizes.empty() ? 0 : sizes[0]); - result += ", data_gen="; - result += (data_gen_type == DataGenType::FIXED) ? "FIXED" : "RANDOM"; - result += ")"; - return result; - case SpecType::Int: - result += "type=Int, value="; - result += std::to_string(get_int_value()); - result += ", data_gen="; - result += (data_gen_type == DataGenType::FIXED) ? "FIXED" : "RANDOM"; - result += ")"; - return result; - case SpecType::Float: - result += "type=Float, value="; - result += std::to_string(get_float_value()); - result += ", data_gen="; - result += (data_gen_type == DataGenType::FIXED) ? "FIXED" : "RANDOM"; - result += ")"; - return result; - case SpecType::Bool: - result += "type=Bool, value="; - result += get_bool_value() ? "true" : "false"; - result += ", data_gen="; - result += (data_gen_type == DataGenType::FIXED) ? "FIXED" : "RANDOM"; - result += ")"; - return result; - case SpecType::String: - result += "type=String, value=\""; - result += get_string_value(); - result += "\")"; - return result; - } - - for (size_t i = 0; i < sizes.size(); ++i) { - result += std::to_string(sizes[i]); - if (i < sizes.size() - 1) { - result += ", "; - } - } - result += "]"; - - if (spec_type == SpecType::Tensor) { - result += ", dtype="; - // NOLINTNEXTLINE(clang-diagnostic-switch-enum) - switch (dtype) { - case vkapi::kFloat: - result += "float"; - break; - case vkapi::kHalf: - result += "half"; - break; - case vkapi::kInt: - result += "int32"; - break; - case vkapi::kChar: - result += "int8"; - break; - case vkapi::kByte: - result += "uint8"; - break; - default: - result += "unknown"; - break; - } - - result += ", memory_layout="; - // NOLINTNEXTLINE(clang-diagnostic-switch-enum) - switch (memory_layout) { - case utils::kWidthPacked: - result += "WidthPacked"; - break; - case utils::kHeightPacked: - result += "HeightPacked"; - break; - case utils::kChannelsPacked: - result += "ChannelsPacked"; - break; - default: - result += "unknown"; - break; - } - - result += ", storage_type="; - // NOLINTNEXTLINE(clang-diagnostic-switch-enum) - switch (storage_type) { - case utils::kTexture3D: - result += "Texture3D"; - break; - case utils::kBuffer: - result += "Buffer"; - break; - default: - result += "unknown"; - break; - } - } - - result += ", data_gen="; - // NOLINTNEXTLINE(clang-diagnostic-switch-enum) - switch (data_gen_type) { - case DataGenType::FIXED: - result += "FIXED"; - break; - case DataGenType::RANDOM: - result += "RANDOM"; - break; - case DataGenType::RANDINT: - result += "RANDINT"; - break; - case DataGenType::RANDINT8: - result += "RANDINT8"; - break; - case DataGenType::RANDINT4: - result += "RANDINT4"; - break; - case DataGenType::ONES: - result += "ONES"; - break; - case DataGenType::ZEROS: - result += "ZEROS"; - break; - default: - result += "unknown"; - break; - } - result += ")"; - return result; -} - -// Additional ValueSpec methods -void ValueSpec::resize_data(size_t new_size) { - // Generate first so a deferred tensor keeps its data-gen pattern (resized, - // not pinned to zeros by the data_generated_ flag set below). - ensure_data_generated(); - ensure_unique_data(); - // NOLINTNEXTLINE(clang-diagnostic-switch-enum) - switch (dtype) { - case vkapi::kFloat: - data_->float_data.resize(new_size); - break; - case vkapi::kHalf: - data_->half_data.resize(new_size); - break; - case vkapi::kInt: - data_->int32_data.resize(new_size); - break; - case vkapi::kChar: - data_->int8_data.resize(new_size); - break; - case vkapi::kByte: - data_->uint8_data.resize(new_size); - break; - default: - data_->float_data.resize(new_size); - break; - } - data_generated_ = true; -} - -void* ValueSpec::get_mutable_data_ptr() { - ensure_data_generated(); - ensure_unique_data(); - // NOLINTNEXTLINE(clang-diagnostic-switch-enum) - switch (dtype) { - case vkapi::kFloat: - return data_->float_data.data(); - case vkapi::kHalf: - return data_->half_data.data(); - case vkapi::kInt: - return data_->int32_data.data(); - case vkapi::kChar: - return data_->int8_data.data(); - case vkapi::kByte: - return data_->uint8_data.data(); - default: - return data_->float_data.data(); - } -} - -float ValueSpec::get_element(size_t index) const { - ensure_data_generated(); - if (index >= static_cast(numel())) { - return 0.0f; - } - - // NOLINTNEXTLINE(clang-diagnostic-switch-enum) - switch (dtype) { - case vkapi::kFloat: - return index < data_->float_data.size() ? data_->float_data[index] : 0.0f; - case vkapi::kHalf: - return index < data_->half_data.size() - ? half_to_float(data_->half_data[index]) - : 0.0f; - case vkapi::kInt: - return index < data_->int32_data.size() - ? static_cast(data_->int32_data[index]) - : 0.0f; - case vkapi::kChar: - return index < data_->int8_data.size() - ? static_cast(data_->int8_data[index]) - : 0.0f; - case vkapi::kByte: - return index < data_->uint8_data.size() - ? static_cast(data_->uint8_data[index]) - : 0.0f; - default: - return 0.0f; - } -} - -const void* ValueSpec::get_data_ptr() const { - ensure_data_generated(); - // NOLINTNEXTLINE(clang-diagnostic-switch-enum) - switch (dtype) { - case vkapi::kFloat: - return data_->float_data.data(); - case vkapi::kHalf: - return data_->half_data.data(); - case vkapi::kInt: - return data_->int32_data.data(); - case vkapi::kChar: - return data_->int8_data.data(); - case vkapi::kByte: - return data_->uint8_data.data(); - default: - throw std::runtime_error("Unsupported data type for get_data_ptr"); - } -} - -void generate_random_float_data( - std::vector& data, - float min_val, - float max_val, - int explicit_seed) { - std::mt19937 gen(get_seed_or_explicit(explicit_seed)); - std::uniform_real_distribution dis(min_val, max_val); - for (auto& val : data) { - val = dis(gen); - } -} - -void generate_random_int_data( - std::vector& data, - int min_val, - int max_val, - int explicit_seed) { - std::mt19937 gen(get_seed_or_explicit(explicit_seed)); - std::uniform_int_distribution dis(min_val, max_val); - for (auto& val : data) { - val = dis(gen); - } -} - -void generate_randint_float_data( - std::vector& data, - int min_val, - int max_val, - int explicit_seed) { - std::mt19937 gen(get_seed_or_explicit(explicit_seed)); - std::uniform_int_distribution dis(min_val, max_val); - for (auto& val : data) { - val = static_cast(dis(gen)); - } -} - -void generate_randint_half_data( - std::vector& data, - int min_val, - int max_val, - int explicit_seed) { - std::mt19937 gen(get_seed_or_explicit(explicit_seed)); - std::uniform_int_distribution dis(min_val, max_val); - for (auto& val : data) { - val = float_to_half(static_cast(dis(gen))); - } -} - -void generate_ones_data(std::vector& data) { - std::fill(data.begin(), data.end(), 1.0f); -} - -void generate_random_int8_data( - std::vector& data, - int8_t min_val, - int8_t max_val, - int explicit_seed) { - std::mt19937 gen(get_seed_or_explicit(explicit_seed)); - std::uniform_int_distribution dis(min_val, max_val); - for (auto& val : data) { - val = static_cast(dis(gen)); - } -} - -void generate_random_uint8_data( - std::vector& data, - uint8_t min_val, - uint8_t max_val, - int explicit_seed) { - std::mt19937 gen(get_seed_or_explicit(explicit_seed)); - std::uniform_int_distribution dis(min_val, max_val); - for (auto& val : data) { - val = static_cast(dis(gen)); - } -} - -void generate_random_int4_data( - std::vector& data, - int8_t min_val, - int8_t max_val, - int explicit_seed) { - std::mt19937 gen(get_seed_or_explicit(explicit_seed)); - std::uniform_int_distribution dis(min_val, max_val); - for (auto& val : data) { - val = static_cast(dis(gen)); - } -} - -void generate_random_2xint4_data(std::vector& data, int explicit_seed) { - std::mt19937 gen(get_seed_or_explicit(explicit_seed)); - std::uniform_int_distribution dis(-8, 7); // Signed 4-bit range - for (auto& val : data) { - // Generate two separate 4-bit values - int8_t lower_4bits = static_cast(dis(gen)) & 0x0F; - int8_t upper_4bits = static_cast(dis(gen)) & 0x0F; - // Pack them into a single 8-bit value - val = (upper_4bits << 4) | lower_4bits; - } -} - -void generate_random_2xint4_data( - std::vector& data, - int explicit_seed) { - std::mt19937 gen(get_seed_or_explicit(explicit_seed)); - std::uniform_int_distribution dis(0, 15); // Unsigned 4-bit range - for (auto& val : data) { - // Generate two separate 4-bit values - uint8_t lower_4bits = static_cast(dis(gen)) & 0x0F; - uint8_t upper_4bits = static_cast(dis(gen)) & 0x0F; - // Pack them into a single 8-bit value - val = (upper_4bits << 4) | lower_4bits; - } -} - -void generate_zeros_data(std::vector& data) { - std::fill(data.begin(), data.end(), 0.0f); -} - -// Correctness checking against reference data -bool ValueSpec::validate_against_reference( - float abs_tolerance, - float rel_tolerance) const { - // Only validate float and half tensors. For half tensors, convert the - // computed half data to float for comparison against the fp32 reference. - if (!is_tensor() || (dtype != vkapi::kFloat && dtype != vkapi::kHalf)) { - return true; // Skip validation for non-float/half or non-tensor types - } - - // For kHalf, materialize the GPU output as float so the same tolerance - // machinery can compare against the (always-float) reference data. - std::vector half_as_float; - if (dtype == vkapi::kHalf) { - const auto& half_bits = get_half_data(); - half_as_float.resize(half_bits.size()); - for (size_t i = 0; i < half_bits.size(); ++i) { - half_as_float[i] = half_to_float(half_bits[i]); - } - } - // Materialize computed data as float32 for comparison. The dtype is - // guaranteed to be float or half by the early-out above. - const std::vector& computed_data = - (dtype == vkapi::kHalf) ? half_as_float : get_float_data(); - const auto& reference_data = get_ref_float_data(); - - // Skip validation if no reference data is available - if (reference_data.empty()) { - return true; - } - - // Check if sizes match - if (computed_data.size() != reference_data.size()) { - if (debugging()) { - std::cout << "Size mismatch: computed=" << computed_data.size() - << ", reference=" << reference_data.size() << std::endl; - } - return false; - } - - // Element-wise comparison with both absolute and relative tolerance - size_t num_mismatched = 0; - size_t first_mismatch = 0; - for (size_t i = 0; i < computed_data.size(); ++i) { - float diff = std::abs(computed_data[i] - reference_data[i]); - float abs_ref = std::abs(reference_data[i]); - - // Check if either absolute or relative tolerance condition is satisfied - bool abs_tolerance_ok = diff <= abs_tolerance; - bool rel_tolerance_ok = diff <= rel_tolerance * abs_ref; - - if (!abs_tolerance_ok && !rel_tolerance_ok) { - if (num_mismatched == 0) { - first_mismatch = i; - std::cout << "Mismatch at element " << i - << ": computed=" << computed_data[i] - << ", reference=" << reference_data[i] << ", diff=" << diff - << ", abs_tolerance=" << abs_tolerance - << ", rel_tolerance=" << rel_tolerance - << ", rel_threshold=" << (rel_tolerance * abs_ref) - << std::endl; - } - num_mismatched++; - } - } - if (num_mismatched > 0) { - std::cout << " total mismatched: " << num_mismatched << " / " - << computed_data.size() << " (first at " << first_mismatch << ")" - << std::endl; - // For 2D outputs, print a per-16x16-tile mismatch-count map to expose - // the spatial structure of the failure (e.g. zeroed MMA subtiles). - if (sizes.size() == 2) { - const int64_t Mr = sizes[0]; - const int64_t Nc = sizes[1]; - std::cout << " 16x16-tile mismatch counts (rows=M/16, cols=N/16):" - << std::endl; - for (int64_t ti = 0; ti < (Mr + 15) / 16; ++ti) { - std::cout << " "; - for (int64_t tj = 0; tj < (Nc + 15) / 16; ++tj) { - int count = 0; - for (int64_t r = ti * 16; r < std::min(Mr, (ti + 1) * 16); ++r) { - for (int64_t c = tj * 16; c < std::min(Nc, (tj + 1) * 16); ++c) { - float diff = std::abs( - computed_data[r * Nc + c] - reference_data[r * Nc + c]); - float abs_ref = std::abs(reference_data[r * Nc + c]); - if (diff > abs_tolerance && diff > rel_tolerance * abs_ref) { - count++; - } - } - } - std::cout << std::setw(4) << count; - } - std::cout << std::endl; - } - } - return false; - } - - if (debugging()) { - std::cout << "Correctness validation PASSED" << std::endl; - } - return true; -} - -// Ensure data is generated for this ValueSpec -void ValueSpec::ensure_data_generated(int seed) const { - if (data_generated_) { - return; - } - generate_tensor_data(seed); - data_generated_ = true; -} - -void ValueSpec::share_data_from(const ValueSpec& other) { - if (!is_tensor() || !other.is_tensor()) { - return; - } - // Materialize the source first: sharing an ungenerated payload would let a - // later access materialize each spec independently under different seeds. - other.ensure_data_generated(); - data_ = other.data_; - data_generated_ = true; -} - -void ValueSpec::share_reference_from(const ValueSpec& other) { - if (!is_tensor() || !other.is_tensor()) { - return; - } - reference_data_ = other.reference_data_; -} - -// ValueSpec data printing utilities -void print_valuespec_data( - const ValueSpec& spec, - const std::string& name, - const bool print_ref_data, - size_t max_elements, - int precision) { - std::cout << "\n" << name << " Data:" << std::endl; - std::cout << " Type: " << spec.to_string() << std::endl; - - if (!spec.is_tensor()) { - if (spec.is_int()) { - std::cout << " Value: " << spec.get_int_value() << std::endl; - } else if (spec.is_int_list()) { - const auto& int_list = spec.get_int_list(); - std::cout << " Values: ["; - size_t print_count = std::min(max_elements, int_list.size()); - for (size_t i = 0; i < print_count; ++i) { - std::cout << int_list[i]; - if (i < print_count - 1) { - std::cout << ", "; - } - } - if (int_list.size() > max_elements) { - std::cout << ", ... (" << (int_list.size() - max_elements) << " more)"; - } - std::cout << "]" << std::endl; - } - return; - } - - // Print tensor data - size_t total_elements = spec.numel(); - size_t print_count = std::min(max_elements, total_elements); - - std::cout << " Total elements: " << total_elements << std::endl; - std::cout << " Data (first " << print_count << " elements): ["; - - std::cout << std::fixed << std::setprecision(precision); - - // NOLINTNEXTLINE(clang-diagnostic-switch-enum) - switch (spec.dtype) { - case vkapi::kFloat: { - auto data = spec.get_float_data().data(); - if (print_ref_data) { - data = spec.get_ref_float_data().data(); - } - for (size_t i = 0; i < print_count; ++i) { - std::cout << data[i]; - if (i < print_count - 1) { - std::cout << ", "; - } - } - break; - } - case vkapi::kHalf: { - if (print_ref_data) { - const auto& ref = spec.get_ref_float_data(); - for (size_t i = 0; i < print_count; ++i) { - std::cout << ref[i]; - if (i < print_count - 1) { - std::cout << ", "; - } - } - break; - } - const auto& data = spec.get_half_data(); - for (size_t i = 0; i < print_count; ++i) { - // Convert IEEE 754 half-precision bit pattern back to float. - float value = half_to_float(data[i]); - std::cout << value; - if (i < print_count - 1) { - std::cout << ", "; - } - } - break; - } - case vkapi::kInt: { - const auto& data = spec.get_int32_data(); - for (size_t i = 0; i < print_count; ++i) { - std::cout << data[i]; - if (i < print_count - 1) { - std::cout << ", "; - } - } - break; - } - case vkapi::kChar: { - const auto& data = spec.get_int8_data(); - if (spec.is_int4()) { - // Print each 4-bit value individually - size_t element_count = 0; - for (size_t i = 0; i < data.size() && element_count < print_count; - ++i) { - // Extract lower 4 bits (signed) - int8_t lower_4bits = data[i] & 0x0F; - if (lower_4bits > 7) { - lower_4bits -= 16; // Convert to signed - } - std::cout << static_cast(lower_4bits); - element_count++; - - if (element_count < print_count) { - std::cout << ", "; - // Extract upper 4 bits (signed) - int8_t upper_4bits = (data[i] >> 4) & 0x0F; - if (upper_4bits > 7) { - upper_4bits -= 16; // Convert to signed - } - std::cout << static_cast(upper_4bits); - element_count++; - - if (element_count < print_count) { - std::cout << ", "; - } - } - } - } else { - for (size_t i = 0; i < print_count; ++i) { - std::cout << static_cast(data[i]); - if (i < print_count - 1) { - std::cout << ", "; - } - } - } - break; - } - case vkapi::kByte: { - const auto& data = spec.get_uint8_data(); - if (spec.is_int4()) { - // Print each 4-bit value individually - size_t element_count = 0; - for (size_t i = 0; i < data.size() && element_count < print_count; - ++i) { - // Extract lower 4 bits - uint8_t lower_4bits = data[i] & 0x0F; - std::cout << static_cast(lower_4bits); - element_count++; - - if (element_count < print_count) { - std::cout << ", "; - // Extract upper 4 bits - uint8_t upper_4bits = (data[i] >> 4) & 0x0F; - std::cout << static_cast(upper_4bits); - element_count++; - - if (element_count < print_count) { - std::cout << ", "; - } - } - } - } else { - for (size_t i = 0; i < print_count; ++i) { - std::cout << static_cast(data[i]); - if (i < print_count - 1) { - std::cout << ", "; - } - } - } - break; - } - default: - std::cout << "unsupported data type"; - break; - } - - if (total_elements > max_elements) { - std::cout << ", ... (" << (total_elements - max_elements) << " more)"; - } - std::cout << "]" << std::endl; - - // Print some statistics for tensor data - if (total_elements > 0) { - float min_val = 0.0f, max_val = 0.0f, sum = 0.0f; - bool first = true; - - for (size_t i = 0; i < total_elements; ++i) { - float val = spec.get_element(i); - if (first) { - min_val = max_val = val; - first = false; - } else { - min_val = std::min(min_val, val); - max_val = std::max(max_val, val); - } - sum += val; - } - - float mean = sum / total_elements; - std::cout << " Statistics: min=" << std::setprecision(precision) << min_val - << ", max=" << max_val << ", mean=" << mean << ", sum=" << sum - << std::endl; - } -} - -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/framework/value_spec.h b/backends/vulkan/test/ops/benchmark/framework/value_spec.h deleted file mode 100644 index ebb87e2c323..00000000000 --- a/backends/vulkan/test/ops/benchmark/framework/value_spec.h +++ /dev/null @@ -1,404 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#pragma once - -#include - -#include -#include -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { - -using namespace vkcompute; - -// -// ValueSpec class -// - -enum class SpecType { Tensor, IntList, Int, Float, Bool, String }; - -// Data generation types -enum class DataGenType { - FIXED, - RANDOM, - RANDOM_SCALES, - RANDINT, - RANDINT8, - RANDINT4, - ONES, - ONES_INT4, - ZEROS -}; - -// Value specification struct -struct ValueSpec { - std::vector sizes; - vkapi::ScalarType dtype; - utils::GPUMemoryLayout memory_layout; - utils::StorageType storage_type; - SpecType spec_type; - DataGenType data_gen_type; - bool is_constant_tensor; - bool is_none_flag; - bool is_int4_tensor; - std::string string_data; - - ValueSpec( - const std::vector& sizes, - vkapi::ScalarType dtype, - utils::StorageType storage_type = utils::kTexture3D, - utils::GPUMemoryLayout memory_layout = utils::kWidthPacked) - : sizes(sizes), - dtype(dtype), - memory_layout(memory_layout), - storage_type(storage_type), - spec_type(SpecType::Tensor), - data_gen_type(DataGenType::ZEROS), - is_constant_tensor(false), - is_none_flag(false), - is_int4_tensor(false), - data_generated_(false) { - // Data generation is deferred until first access (any data getter or - // ensure_data_generated() triggers it). - } - - // Constructor for tensor with custom data generation type - ValueSpec( - const std::vector& sizes, - vkapi::ScalarType dtype, - utils::StorageType storage_type, - utils::GPUMemoryLayout memory_layout, - DataGenType data_gen_type) - : sizes(sizes), - dtype(dtype), - memory_layout(memory_layout), - storage_type(storage_type), - spec_type(SpecType::Tensor), - data_gen_type(data_gen_type), - is_constant_tensor(false), - is_none_flag(false), - is_int4_tensor(false), - data_generated_(false) { - // Data generation is deferred until first access (any data getter or - // ensure_data_generated() triggers it). - } - - // Constructor for single int - ValueSpec(int32_t value) - : sizes({1}), - dtype(vkapi::kInt), - memory_layout(utils::kWidthPacked), - storage_type(utils::kTexture3D), - spec_type(SpecType::Int), - data_gen_type(DataGenType::FIXED), - is_constant_tensor(false), - is_none_flag(false), - is_int4_tensor(false), - data_generated_(true) { - data_->int32_data.push_back(value); - } - - // Constructor for single float - ValueSpec(float value) - : sizes({1}), - dtype(vkapi::kFloat), - memory_layout(utils::kWidthPacked), - storage_type(utils::kTexture3D), - spec_type(SpecType::Float), - data_gen_type(DataGenType::FIXED), - is_constant_tensor(false), - is_none_flag(false), - is_int4_tensor(false), - data_generated_(true) { - data_->float_data.push_back(value); - } - - // Constructor for single bool - ValueSpec(bool value) - : sizes({1}), - dtype(vkapi::kInt), - memory_layout(utils::kWidthPacked), - storage_type(utils::kTexture3D), - spec_type(SpecType::Bool), - data_gen_type(DataGenType::FIXED), - is_constant_tensor(false), - is_none_flag(false), - is_int4_tensor(false), - data_generated_(true) { - data_->int32_data.push_back(value ? 1 : 0); - } - - // Constructor for int list - ValueSpec(const std::vector& values) - : sizes({static_cast(values.size())}), - dtype(vkapi::kInt), - memory_layout(utils::kWidthPacked), - storage_type(utils::kTexture3D), - spec_type(SpecType::IntList), - data_gen_type(DataGenType::FIXED), - is_constant_tensor(false), - is_none_flag(false), - is_int4_tensor(false), - data_generated_(true) { - data_->int32_data = values; - } - - // Factory method for string (avoids ambiguity with vector constructor) - static ValueSpec make_string(const std::string& value) { - ValueSpec spec; - spec.sizes = {1}; - spec.dtype = vkapi::kInt; - spec.memory_layout = utils::kWidthPacked; - spec.storage_type = utils::kTexture3D; - spec.spec_type = SpecType::String; - spec.data_gen_type = DataGenType::FIXED; - spec.is_constant_tensor = false; - spec.is_none_flag = false; - spec.is_int4_tensor = false; - spec.data_generated_ = true; - spec.string_data = value; - return spec; - } - - // Default constructor - ValueSpec() - : dtype(vkapi::kFloat), - memory_layout(utils::kWidthPacked), - storage_type(utils::kTexture3D), - spec_type(SpecType::Tensor), - data_gen_type(DataGenType::ZEROS), - is_constant_tensor(false), - is_none_flag(false), - is_int4_tensor(false), - data_generated_(false) {} - - int64_t numel() const; - size_t nbytes() const; - std::string to_string() const; - - bool is_tensor() const { - return spec_type == SpecType::Tensor; - } - bool is_int_list() const { - return spec_type == SpecType::IntList; - } - bool is_int() const { - return spec_type == SpecType::Int; - } - bool is_float() const { - return spec_type == SpecType::Float; - } - bool is_bool() const { - return spec_type == SpecType::Bool; - } - bool is_string() const { - return spec_type == SpecType::String; - } - - int32_t get_int_value() const { - ensure_data_generated(); - return data_->int32_data.empty() ? 0 : data_->int32_data[0]; - } - float get_float_value() const { - ensure_data_generated(); - return data_->float_data.empty() ? 0.0f : data_->float_data[0]; - } - bool get_bool_value() const { - ensure_data_generated(); - return data_->int32_data.empty() ? false : (data_->int32_data[0] != 0); - } - const std::string& get_string_value() const { - return string_data; - } - const std::vector& get_int_list() const { - ensure_data_generated(); - return data_->int32_data; - } - const std::vector& get_tensor_sizes() const { - return sizes; - } - - // References and pointers into tensor data must not be held across any other - // access to the same spec: a mutable access may detach the shared payload, - // leaving a previously returned reference bound to the old payload. Consume - // immediately. - const std::vector& get_float_data() const { - ensure_data_generated(); - return data_->float_data; - } - const std::vector& get_int32_data() const { - ensure_data_generated(); - return data_->int32_data; - } - const std::vector& get_half_data() const { - ensure_data_generated(); - return data_->half_data; - } - const std::vector& get_int8_data() const { - ensure_data_generated(); - return data_->int8_data; - } - const std::vector& get_uint8_data() const { - ensure_data_generated(); - return data_->uint8_data; - } - - std::vector& get_float_data() { - ensure_data_generated(); - ensure_unique_data(); - return data_->float_data; - } - std::vector& get_int32_data() { - ensure_data_generated(); - ensure_unique_data(); - return data_->int32_data; - } - std::vector& get_half_data() { - ensure_data_generated(); - ensure_unique_data(); - return data_->half_data; - } - std::vector& get_int8_data() { - ensure_data_generated(); - ensure_unique_data(); - return data_->int8_data; - } - std::vector& get_uint8_data() { - ensure_data_generated(); - ensure_unique_data(); - return data_->uint8_data; - } - - const std::vector& get_ref_float_data() const { - return reference_data_->float_data; - } - const std::vector& get_ref_int32_data() const { - return reference_data_->int32_data; - } - const std::vector& get_ref_half_data() const { - return reference_data_->half_data; - } - const std::vector& get_ref_int8_data() const { - return reference_data_->int8_data; - } - const std::vector& get_ref_uint8_data() const { - return reference_data_->uint8_data; - } - - std::vector& get_ref_float_data() { - ensure_unique_reference_data(); - return reference_data_->float_data; - } - std::vector& get_ref_int32_data() { - ensure_unique_reference_data(); - return reference_data_->int32_data; - } - std::vector& get_ref_half_data() { - ensure_unique_reference_data(); - return reference_data_->half_data; - } - std::vector& get_ref_int8_data() { - ensure_unique_reference_data(); - return reference_data_->int8_data; - } - std::vector& get_ref_uint8_data() { - ensure_unique_reference_data(); - return reference_data_->uint8_data; - } - - void resize_data(size_t new_size); - void* get_mutable_data_ptr(); - float get_element(size_t index) const; - - // Data generation methods for deferred generation and caching. - // - // ValueSpec is not thread-safe: lazy materialization and copy-on-write - // detach mutate shared state from const methods. Test cases are built and - // executed on a single thread. - // - // Implicit materialization (any data getter, resize_data) consumes the - // global seed counter. Callers needing deterministic data must call - // ensure_data_generated(explicit_seed) before any other access; a later - // seeded call is a no-op once data is generated. - bool is_data_generated() const { - return data_generated_; - } - void ensure_data_generated(int seed = -1) const; - void share_data_from(const ValueSpec& other); - void share_reference_from(const ValueSpec& other); - - // Set/get constant flag - bool is_constant() const { - return is_constant_tensor; - } - void set_constant(bool is_constant) { - is_constant_tensor = is_constant; - } - - // Set/get none flag - bool is_none() const { - return is_none_flag; - } - - void set_none(bool is_none) { - is_none_flag = is_none; - } - - // Set/get int4 flag - bool is_int4() const { - return is_int4_tensor; - } - void set_int4(bool is_int4) { - is_int4_tensor = is_int4; - } - - const void* get_data_ptr() const; - - // Correctness checking against reference data - // Returns true if computed data matches reference data within tolerance - // Only validates float tensors as specified in requirements - bool validate_against_reference( - float abs_tolerance = 2e-3f, - float rel_tolerance = 1e-3f) const; - - private: - struct TensorData { - std::vector float_data; - std::vector int32_data; - std::vector half_data; - std::vector int8_data; - std::vector uint8_data; - }; - - void ensure_unique_data() const; - void ensure_unique_reference_data() const; - void generate_tensor_data(int seed = -1) const; - - mutable bool data_generated_ = false; - mutable std::shared_ptr data_ = std::make_shared(); - mutable std::shared_ptr reference_data_ = - std::make_shared(); -}; - -void print_valuespec_data( - const ValueSpec& spec, - const std::string& name = "ValueSpec", - const bool print_ref_data = false, - size_t max_elements = 20, - int precision = 6); - -// Half-precision conversion utilities -uint16_t float_to_half(float value); -float half_to_float(uint16_t half_val); - -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/framework/weight_utils.cpp b/backends/vulkan/test/ops/benchmark/framework/weight_utils.cpp deleted file mode 100644 index 5161178d978..00000000000 --- a/backends/vulkan/test/ops/benchmark/framework/weight_utils.cpp +++ /dev/null @@ -1,247 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#include "weight_utils.h" -#include "config.h" - -#include - -#include -#include -#include -#include -#include -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { - -ValueRef quantized_weights_canvas( - ComputeGraph& graph, - const ValueRef weight_ref) { - const auto original_sizes = graph.sizes_of(weight_ref); - - std::vector sorted_sizes = original_sizes; - std::sort(sorted_sizes.begin(), sorted_sizes.end(), std::greater()); - int64_t largest1 = sorted_sizes.size() > 0 ? sorted_sizes[0] : 0; - - std::vector final_sizes = {1, largest1, largest1}; - - // Debug logging if debugging flag is set - if (debugging()) { - std::cout << "Debug: Creating quantized weights canvas tensor" << std::endl; - std::cout << "Debug: Original sizes: ["; - for (size_t i = 0; i < original_sizes.size(); ++i) { - std::cout << original_sizes[i]; - if (i < original_sizes.size() - 1) - std::cout << ", "; - } - std::cout << "]" << std::endl; - std::cout << "Debug: Canvas sizes: ["; - for (size_t i = 0; i < final_sizes.size(); ++i) { - std::cout << final_sizes[i]; - if (i < final_sizes.size() - 1) - std::cout << ", "; - } - std::cout << "]" << std::endl; - } - - ValueRef packed_weight = graph.add_tensor( - final_sizes, vkapi::kInt, utils::kTexture3D, utils::kWidthPacked); - - std::string kernel_name = "packed_int32_canvas"; - add_storage_type_suffix(kernel_name, graph.storage_type_of(packed_weight)); - - graph.prepack_nodes().emplace_back(new PrepackNode( - graph, - VK_KERNEL_FROM_STR(kernel_name), - graph.create_gwg(packed_weight), - graph.create_lwg(packed_weight), - weight_ref, - packed_weight, - // UBOs - {graph.logical_limits_ubo(packed_weight)}, - // Specialization constants - {}, - // Push Constants - {})); - - return packed_weight; -} - -ValueRef float_tensor_canvas(ComputeGraph& graph, const ValueRef weight_ref) { - const auto original_sizes = graph.sizes_of(weight_ref); - - std::vector sorted_sizes = original_sizes; - std::sort(sorted_sizes.begin(), sorted_sizes.end(), std::greater()); - int64_t largest1 = sorted_sizes.size() > 0 ? sorted_sizes[0] : 0; - - std::vector final_sizes = {1, largest1, largest1}; - - // Debug logging if debugging flag is set - if (debugging()) { - std::cout << "Debug: Creating float tensor canvas" << std::endl; - std::cout << "Debug: Original sizes: ["; - for (size_t i = 0; i < original_sizes.size(); ++i) { - std::cout << original_sizes[i]; - if (i < original_sizes.size() - 1) - std::cout << ", "; - } - std::cout << "]" << std::endl; - std::cout << "Debug: Canvas sizes: ["; - for (size_t i = 0; i < final_sizes.size(); ++i) { - std::cout << final_sizes[i]; - if (i < final_sizes.size() - 1) - std::cout << ", "; - } - std::cout << "]" << std::endl; - } - - ValueRef packed_weight = graph.add_tensor( - final_sizes, vkapi::kFloat, utils::kTexture3D, utils::kWidthPacked); - - graph.prepack_nodes().emplace_back(new PrepackNode( - graph, - VK_KERNEL_FROM_STR("float_canvas"), - graph.create_gwg(packed_weight), - graph.create_lwg(packed_weight), - weight_ref, - packed_weight, - // UBOs - {graph.logical_limits_ubo(packed_weight)}, - // Specialization constants - {}, - // Push Constants - {})); - - return packed_weight; -} - -// Compute weight sums for quantized operations (linear and convolution) -void compute_weight_sums( - ValueSpec& weight_sums, - const ValueSpec& quantized_weight, - int64_t out_features, - int64_t elements_per_output_feature) { - auto& weight_sums_data = weight_sums.get_int32_data(); - auto& quantized_weight_data = quantized_weight.get_int8_data(); - - // Don't resize down - the buffer may be pre-allocated with aligned size. - // Only resize up if needed. - if (weight_sums_data.size() < static_cast(out_features)) { - weight_sums_data.resize(out_features); - } - - // For each output feature, compute the sum of quantized weights - for (int64_t out_f = 0; out_f < out_features; ++out_f) { - int32_t sum = 0; - for (int64_t elem = 0; elem < elements_per_output_feature; ++elem) { - // Weight indexing depends on the layout: - // For linear: [out_features, in_features] -> out_f * - // elements_per_output_feature + elem For conv2d: [C_out, C_in * K_h * - // K_w] -> out_f * elements_per_output_feature + elem - int64_t weight_idx = out_f * elements_per_output_feature + elem; - sum += static_cast(quantized_weight_data[weight_idx]); - } - weight_sums_data[out_f] = sum; - } -} - -// Compute weight sums for 4D quantized conv2d operations -// Weight layout: [C_out, K_h, K_w, align_up_4(C_in_per_group)] -void compute_weight_sums_4d( - ValueSpec& weight_sums, - const ValueSpec& quantized_weight, - int64_t out_channels, - int64_t kernel_h, - int64_t kernel_w, - int64_t aligned_in_channels) { - auto& weight_sums_data = weight_sums.get_int32_data(); - auto& quantized_weight_data = quantized_weight.get_int8_data(); - - weight_sums_data.resize(out_channels); - - // For each output channel, compute the sum of quantized weights - for (int64_t out_c = 0; out_c < out_channels; ++out_c) { - int32_t sum = 0; - - for (int64_t kh = 0; kh < kernel_h; ++kh) { - for (int64_t kw = 0; kw < kernel_w; ++kw) { - for (int64_t in_c = 0; in_c < aligned_in_channels; ++in_c) { - // Weight indexing: [out_c, kh, kw, in_c] - int64_t weight_idx = - out_c * (kernel_h * kernel_w * aligned_in_channels) + - kh * (kernel_w * aligned_in_channels) + kw * aligned_in_channels + - in_c; - sum += static_cast(quantized_weight_data[weight_idx]); - } - } - } - - weight_sums_data[out_c] = sum; - } -} - -// Helper function to unpack 4-bit values from uint8 (same as in -// q4gsw_linear.cpp) -std::pair unpack_4bit_utils(uint8_t packed) { - // Extract lower 4 bits and upper 4 bits - int8_t lower = packed & 0x0F; - int8_t upper = (packed >> 4) & 0x0F; - - // Subtract 8 from unpacked 4-bit values - lower -= 8; - upper -= 8; - - return std::make_pair(lower, upper); -} - -// Compute weight sums for 4-bit group symmetric quantized weights -void compute_weight_sums_4bit_grouped( - ValueSpec& weight_sums, - const ValueSpec& quantized_weight, - int64_t num_groups, - int64_t out_features, - int64_t group_size) { - auto& weight_sums_data = weight_sums.get_int32_data(); - auto& quantized_weight_data = quantized_weight.get_uint8_data(); - - // Resize to [num_groups, out_features] - weight_sums_data.resize(num_groups * out_features); - - // For each group and each output feature, compute the sum of quantized - // weights in that group - for (int64_t group_idx = 0; group_idx < num_groups; ++group_idx) { - for (int64_t out_f = 0; out_f < out_features; ++out_f) { - int32_t sum = 0; - - // Sum weights for this group and output feature - for (int64_t in_group = 0; in_group < group_size; ++in_group) { - int64_t in_f = group_idx * group_size + in_group; - - // Get packed weight value - weight matrix is [N, K/2] - int64_t weight_idx = - out_f * ((num_groups * group_size) / 2) + (in_f / 2); - uint8_t packed_weight = quantized_weight_data[weight_idx]; - - // Unpack 4-bit weight - auto unpacked = unpack_4bit_utils(packed_weight); - int8_t weight_4bit = (in_f % 2 == 0) ? unpacked.first : unpacked.second; - - sum += static_cast(weight_4bit); - } - - // Store sum for this group and output feature - int64_t sums_idx = group_idx * out_features + out_f; - weight_sums_data[sums_idx] = sum; - } - } -} - -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/framework/weight_utils.h b/backends/vulkan/test/ops/benchmark/framework/weight_utils.h deleted file mode 100644 index 7c9319703b9..00000000000 --- a/backends/vulkan/test/ops/benchmark/framework/weight_utils.h +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. -// All rights reserved. -// -// This source code is licensed under the BSD-style license found in the -// LICENSE file in the root directory of this source tree. - -#pragma once - -#include - -#include "value_spec.h" - -#include - -namespace executorch { -namespace vulkan { -namespace prototyping { - -ValueRef quantized_weights_canvas( - ComputeGraph& graph, - const ValueRef weight_ref); - -ValueRef float_tensor_canvas(ComputeGraph& graph, const ValueRef weight_ref); - -// Compute weight sums for quantized operations (linear and convolution) -void compute_weight_sums( - ValueSpec& weight_sums, - const ValueSpec& quantized_weight, - int64_t out_features, - int64_t elements_per_output_feature); - -// Compute weight sums for 4D quantized conv2d operations -// Weight layout: [C_out, K_h, K_w, align_up_4(C_in_per_group)] -void compute_weight_sums_4d( - ValueSpec& weight_sums, - const ValueSpec& quantized_weight, - int64_t out_channels, - int64_t kernel_h, - int64_t kernel_w, - int64_t aligned_in_channels); - -// Compute weight sums for 4-bit group symmetric quantized weights -void compute_weight_sums_4bit_grouped( - ValueSpec& weight_sums, - const ValueSpec& quantized_weight, - int64_t num_groups, - int64_t out_features, - int64_t group_size); - -} // namespace prototyping -} // namespace vulkan -} // namespace executorch diff --git a/backends/vulkan/test/ops/benchmark/targets.bzl b/backends/vulkan/test/ops/benchmark/targets.bzl deleted file mode 100644 index d53f252cc65..00000000000 --- a/backends/vulkan/test/ops/benchmark/targets.bzl +++ /dev/null @@ -1,109 +0,0 @@ -load("@fbsource//xplat/executorch/build:runtime_wrapper.bzl", "runtime") -load( - "@fbsource//xplat/executorch/backends/vulkan:targets.bzl", - "get_platforms", - "vulkan_spv_shader_lib", -) - -def define_common_targets(is_fbcode = False): - if is_fbcode: - return - - # Shader library from GLSL files - runtime.filegroup( - name = "custom_ops_shaders", - srcs = native.glob([ - "test_ops/glsl/**/*", - ]), - visibility = ["PUBLIC"], - ) - - vulkan_spv_shader_lib( - name = "custom_ops_shaderlib", - spv_filegroups = { - ":custom_ops_shaders": "test_ops/glsl", - }, - is_fbcode = is_fbcode, - ) - - # Prototyping utilities library - runtime.cxx_library( - name = "prototyping_utils", - srcs = [ - "framework/cm_utils.cpp", - "framework/config.cpp", - "framework/conv2d_utils.cpp", - "framework/device_info.cpp", - "framework/registry.cpp", - "framework/results.cpp", - "framework/runner.cpp", - "framework/test_case.cpp", - "framework/value_spec.cpp", - "framework/weight_utils.cpp", - ], - headers = [ - "framework/cm_utils.h", - "framework/config.h", - "framework/conv2d_utils.h", - "framework/device_info.h", - "framework/labels.h", - "framework/registry.h", - "framework/results.h", - "framework/runner.h", - "framework/test_case.h", - "framework/utils.h", - "framework/value_spec.h", - "framework/weight_utils.h", - ], - exported_headers = [ - "framework/cm_utils.h", - "framework/config.h", - "framework/conv2d_utils.h", - "framework/device_info.h", - "framework/labels.h", - "framework/registry.h", - "framework/results.h", - "framework/runner.h", - "framework/test_case.h", - "framework/utils.h", - "framework/value_spec.h", - "framework/weight_utils.h", - ], - platforms = get_platforms(), - deps = [ - "//executorch/backends/vulkan:vulkan_graph_runtime", - ], - visibility = ["PUBLIC"], - ) - - # Operator implementations library - runtime.cxx_library( - name = "operator_implementations", - srcs = native.glob([ - "test_ops/impl/*.cpp", - ]), - platforms = get_platforms(), - deps = [ - "//executorch/backends/vulkan:vulkan_graph_runtime", - ":custom_ops_shaderlib", - ], - visibility = ["PUBLIC"], - link_whole = True, - ) - - runtime.cxx_binary( - name = "etvk_bench", - srcs = ["etvk_bench.cpp"] + native.glob(["cases/**/*.cpp"]), - headers = native.glob(["cases/**/*.h"]), - platforms = get_platforms(), - define_static_target = False, - deps = [ - ":custom_ops_shaderlib", - ":operator_implementations", - ":prototyping_utils", - "//executorch/backends/vulkan:vulkan_graph_runtime", - ], - external_deps = [ - "gflags", - ], - ) diff --git a/backends/vulkan/test/ops/benchmark/test_ops/glsl/add.yaml b/backends/vulkan/test/ops/benchmark/test_ops/glsl/add.yaml deleted file mode 100644 index dd479cafd31..00000000000 --- a/backends/vulkan/test/ops/benchmark/test_ops/glsl/add.yaml +++ /dev/null @@ -1,29 +0,0 @@ -# Copyright (c) Meta Platforms, Inc. and affiliates. -# All rights reserved. -# -# This source code is licensed under the BSD-style license found in the -# LICENSE file in the root directory of this source tree. - -add_buffer: - parameter_names_with_default_values: - NDIM: 3 - DTYPE: float - PACKING: C_packed - generate_variant_forall: - DTYPE: - - VALUE: half - - VALUE: float - shader_variants: - - NAME: add_buffer - -add_texture: - parameter_names_with_default_values: - NDIM: 3 - DTYPE: float - PACKING: C_packed - generate_variant_forall: - DTYPE: - - VALUE: half - - VALUE: float - shader_variants: - - NAME: add_texture3d diff --git a/backends/vulkan/test/ops/benchmark/test_ops/glsl/add_buffer.glsl b/backends/vulkan/test/ops/benchmark/test_ops/glsl/add_buffer.glsl deleted file mode 100644 index a7337dd0cdc..00000000000 --- a/backends/vulkan/test/ops/benchmark/test_ops/glsl/add_buffer.glsl +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. - */ - -#version 450 core - -#define PRECISION ${PRECISION} - -#define VEC4_T ${texel_type(DTYPE)} -#define T ${buffer_scalar_type(DTYPE)} - -${define_active_storage_type("buffer")} -${define_required_extensions("buffer", DTYPE)} - -layout(std430) buffer; - -${layout_declare_tensor(B, "w", "t_out", DTYPE, "buffer")} -${layout_declare_tensor(B, "r", "t_in", DTYPE, "buffer")} -${layout_declare_tensor(B, "r", "t_other", DTYPE, "buffer")} - -layout(push_constant) uniform restrict Block { - int out_numel; -}; - -layout(local_size_x_id = 0, local_size_y_id = 1, local_size_z_id = 2) in; - -void main() { - const int out_bufi = int(gl_GlobalInvocationID.x); - if (out_bufi >= out_numel) { - return; - } - - // Simple addition without broadcasting - t_out[out_bufi] = t_in[out_bufi] + t_other[out_bufi]; -} \ No newline at end of file diff --git a/backends/vulkan/test/ops/benchmark/test_ops/glsl/add_texture.glsl b/backends/vulkan/test/ops/benchmark/test_ops/glsl/add_texture.glsl deleted file mode 100644 index e3518c025a0..00000000000 --- a/backends/vulkan/test/ops/benchmark/test_ops/glsl/add_texture.glsl +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. - */ - -#version 450 core - -#define PRECISION ${PRECISION} - -#define VEC4_T ${texel_type(DTYPE)} - -${define_active_storage_type("texture3d")} -${define_required_extensions("texture3d", DTYPE)} - -layout(std430) buffer; - -${layout_declare_tensor(B, "w", "t_out", DTYPE, "texture3d")} -${layout_declare_tensor(B, "r", "t_in", DTYPE, "texture3d")} -${layout_declare_tensor(B, "r", "t_other", DTYPE, "texture3d")} - -layout(push_constant) uniform restrict Block { - ivec4 out_sizes; -}; - -layout(local_size_x_id = 0, local_size_y_id = 1, local_size_z_id = 2) in; - -${layout_declare_spec_const(C, "int", "out_layout", "0")} - -void main() { - const ivec3 pos = ivec3(gl_GlobalInvocationID); - - // Simple addition without broadcasting - same position for all tensors - VEC4_T in_texel = texelFetch(t_in, pos, 0); - VEC4_T other_texel = texelFetch(t_other, pos, 0); - - imageStore(t_out, pos, in_texel + other_texel); -} diff --git a/backends/vulkan/test/ops/benchmark/test_ops/glsl/float_canvas.glsl b/backends/vulkan/test/ops/benchmark/test_ops/glsl/float_canvas.glsl deleted file mode 100644 index f821fa3586f..00000000000 --- a/backends/vulkan/test/ops/benchmark/test_ops/glsl/float_canvas.glsl +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. - */ - -#version 450 core - -#define PRECISION ${PRECISION} - -${define_active_storage_type("texture3d")} - -layout(std430) buffer; - -${layout_declare_tensor(B, "w", "t_out", "float", "texture3d")} -${layout_declare_tensor(B, "r", "nchw_in", "uint", "buffer")} - -${layout_declare_ubo(B, "ivec3", "out_limits")} - -layout(local_size_x_id = 0, local_size_y_id = 1, local_size_z_id = 2) in; - -void main() { - const ivec3 lpos = ivec3(gl_GlobalInvocationID); - - if (any(greaterThanEqual(lpos, out_limits))) { - return; - } - - // Placeholder: just copy input to output - vec4 in_texel = vec4(1.0f); - imageStore(t_out, lpos, in_texel); -} diff --git a/backends/vulkan/test/ops/benchmark/test_ops/glsl/packed_int32_canvas_buffer.glsl b/backends/vulkan/test/ops/benchmark/test_ops/glsl/packed_int32_canvas_buffer.glsl deleted file mode 100644 index e2d198b129f..00000000000 --- a/backends/vulkan/test/ops/benchmark/test_ops/glsl/packed_int32_canvas_buffer.glsl +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. - */ - -#version 450 core - -#define PRECISION ${PRECISION} - -${define_active_storage_type("texture3d")} - -layout(std430) buffer; - -${layout_declare_tensor(B, "w", "t_out", "int", "texture3d")} -${layout_declare_tensor(B, "r", "nchw_in", "uint", "buffer")} - -${layout_declare_ubo(B, "ivec3", "out_limits")} - -layout(local_size_x_id = 0, local_size_y_id = 1, local_size_z_id = 2) in; - -void main() { - const ivec3 lpos = ivec3(gl_GlobalInvocationID); - - if (any(greaterThanEqual(lpos, out_limits))) { - return; - } - - // Pack four 8-bit values equal to 1 into a single uint - int packed = (1 << 0) | (1 << 8) | (1 << 16) | (1 << 24); - - // Placeholder: just copy input to output - ivec4 in_texel = ivec4(packed); - imageStore(t_out, lpos, in_texel); -} diff --git a/backends/vulkan/test/ops/benchmark/test_ops/glsl/packed_int32_canvas_texture3d.glsl b/backends/vulkan/test/ops/benchmark/test_ops/glsl/packed_int32_canvas_texture3d.glsl deleted file mode 100644 index 80e6fc27909..00000000000 --- a/backends/vulkan/test/ops/benchmark/test_ops/glsl/packed_int32_canvas_texture3d.glsl +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. - */ - -#version 450 core - -#define PRECISION ${PRECISION} - -${define_active_storage_type("texture2d")} - -layout(std430) buffer; - -${layout_declare_tensor(B, "w", "t_out", "int", "texture3d")} -${layout_declare_tensor(B, "r", "nchw_in", "uint", "buffer")} - -${layout_declare_ubo(B, "ivec3", "out_limits")} - -layout(local_size_x_id = 0, local_size_y_id = 1, local_size_z_id = 2) in; - -void main() { - const ivec3 lpos = ivec3(gl_GlobalInvocationID); - - if (any(greaterThanEqual(lpos, out_limits))) { - return; - } - - // Pack four 8-bit values equal to 1 into a single uint - int packed = (1 << 0) | (1 << 8) | (1 << 16) | (1 << 24); - - // Placeholder: just copy input to output - ivec4 in_texel = ivec4(packed); - imageStore(t_out, lpos, in_texel); -} diff --git a/backends/vulkan/test/ops/benchmark/test_ops/glsl/q4gsw_linear_gemv__w_4x8.glsl b/backends/vulkan/test/ops/benchmark/test_ops/glsl/q4gsw_linear_gemv__w_4x8.glsl deleted file mode 100644 index e3c6ccdfba7..00000000000 --- a/backends/vulkan/test/ops/benchmark/test_ops/glsl/q4gsw_linear_gemv__w_4x8.glsl +++ /dev/null @@ -1,371 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. - */ - -// q4gsw linear GEMV kernel — row-pair broadcast dequant-accumulate over the -// shared w_4x8 weight prepack. -// -// Shader naming convention: -// q4gsw_linear_gemv__w_4x8_[_nosg] -// ^^^^^^^^^^^^^^^^^ ^^^^^ ^^^^^^^ -// op base (gemv) tile tile arrangement (nc or kc) -// -// Weight binding: -// The shared pack_q4_linear_weight__w_4x8 shader writes a W_4X8 block-packed uvec2 buffer -// where the uvec2 at logical tile index [k4, n4] lives at the 2 consecutive -// uint slots: -// t_q4_weights[2 * tile_idx + 0] = .x (row pair {N0, N1}) -// t_q4_weights[2 * tile_idx + 1] = .y (row pair {N2, N3}) -// Read as a scalar uint buffer, the uint at -// word_idx = 2 * tile_idx + half (half in {0, 1}) -// is one N-row-pair's 4 K-step payload. With n2 = 2 * n4 + half and -// k_slot = k4, under WEIGHT_TILE_CONTIG_DIM=0: -// word_idx = k_slot * (N/2) + n2 -// which is the index formula used by the per-row-pair loop here. -// -// Interleaved (dp4a-style) byte-pair layout: each uvec2 lane's 4 bytes hold -// 4 K-consecutive positions for a pair of N rows. As a scalar uint, byte b -// of w_pack = (N_even, K=b) | (N_odd, K=b) << 4 — the low nibble per byte -// is the even-N row, the high nibble is odd-N. This byte packing is the -// natural memory split for the paired-row dequant below and lets the same -// shader body be repurposed later for int8/int4 integer matmul that -// operates directly on byte-interleaved nibble pairs. -// -// Scale binding: -// Uses the production dtype-matched scale bytes but reinterprets them as a -// gvec2 (vec2 / f16vec2) array. The scale prepack emits vec4 bytes indexed -// as t_scales[group_i * N4 + n4] where each vec4 holds 4 N-row scales. The -// same byte layout is addressable as vec2 with index -// vec2_idx = 2 * (group_i * N4 + n4) + half = group_i * N2 + n2 -// since each vec4 = 2 consecutive vec2 slots (low half = rows {2*n4, 2*n4+1}, -// high half = rows {2*n4+2, 2*n4+3}). Binding as vec2 halves the scale load -// byte volume and eliminates the 2 wasted components per load. -// -// WG layout: SUBGROUP_SIZE=64, NUM_SUBGROUPS=4. y-dim splits K-blocks across waves. -// Each thread owns one row-pair (n2) and writes two output floats. - -#version 450 core - -${define_required_extensions(IO_STORAGE, DTYPE)} -${define_required_extensions("buffer", DTYPE)} -#extension GL_EXT_shader_explicit_arithmetic_types_float16 : require -#extension GL_EXT_control_flow_attributes : require -$if USE_SUBGROUP_BROADCAST: - #extension GL_KHR_shader_subgroup_basic : require - #extension GL_KHR_shader_subgroup_ballot : require - #extension GL_KHR_shader_subgroup_shuffle : require - -#define PRECISION ${PRECISION} - -#define T ${texel_load_component_type(DTYPE, "buffer")} - -$if IO_STORAGE == "buffer": - #define IO_BUFFER - -#define NUM_SUBGROUPS ${NUM_SUBGROUPS} -#define SUBGROUP_SIZE ${SUBGROUP_SIZE} -// Workgroup x-dim size — used for shared-mem indexing in the inter-wave -// reduction. Chosen to match LWG.x set by the host (kGemvSubgroupSize=64). -// In the sg variant, lanes happen to align 1:1 with x-threads when -// subgroupSize >= LWG_X_SIZE; in the nosg variant, x-thread index alone -// addresses shared-mem slots so any subgroup width is safe. -#define LWG_X_SIZE ${LWG_X_SIZE} -// Number of K elements processed per outer k-loop iteration. Format-level -// constant — each iteration loads 8 vec4 of activations (= 32 K-vals) and 8 -// uint32 weight packs (4 hi + 4 lo, each holding 4 K-vals × 2 N-rows). Distinct -// from `group_size` (the quantization group): blocks_per_group = group_size / -// K_PER_STEP tells how many consecutive K-blocks share one scale pair. -#define K_PER_STEP 32 - -#define WEIGHT_TILE_CONTIG_DIM ${WEIGHT_TILE_CONTIG_DIM} - -layout(std430) buffer; - -// Unified 6-binding layout shared across q4gsw_linear shaders so a single -// DynamicDispatchNode with pick_shader_fn can switch between GEMM and GEMV -// kernels. This shader reads t_fp_input (the raw activation). The -// t_transposed_input binding is declared to preserve slot order but is never -// referenced here — the driver compiles it out to zero runtime cost; only -// the descriptor slot is allocated. -// -// Output: [1, N] scalar DTYPE buffer OR 1x1xN/4 texture3d. -// is_scalar_array is only meaningful for buffer storage; ignored for texture. -${layout_declare_tensor(B, "w", "t_output", DTYPE, IO_STORAGE, is_scalar_array=True)} -// Activations: [1, K] vec4-packed. -${layout_declare_tensor(B, "r", "t_fp_input", DTYPE, IO_STORAGE, is_scalar_array=False)} -// Unused transposed input — declared only so this shader shares the -// descriptor set layout with the tin GEMM shader. -${layout_declare_tensor(B, "r", "t_transposed_input", DTYPE, "buffer", is_scalar_array=False)} -// Weight: same uvec2 W_4X8 block-packed buffer produced by pack_q4_linear_weight__w_4x8, -// bound here as a scalar uint array so the per-row-pair index math can address -// individual uint slots directly. See header comment for byte-layout proof. -${layout_declare_tensor(B, "r", "t_q4_weights", "int", "buffer")} -// Scales: dtype-matched gvec2 reinterpret of the GEMM vec4 scale prepack. -// Indexed as t_scales[group_idx * N2 + n2]. -${layout_declare_tensor(B, "r", "t_scales", DTYPE, "buffer", is_scalar_array=False, vec_size=2)} -// Bias: [N] DTYPE buffer. -${layout_declare_tensor(B, "r", "t_bias", DTYPE, "buffer", is_scalar_array=True)} - -${layout_declare_ubo(B, "ivec4", "output_sizes")} -${layout_declare_ubo(B, "ivec4", "input_sizes")} - -layout(local_size_x_id = 0, local_size_y_id = 1, local_size_z_id = 2) in; - -${layout_declare_spec_const(C, "int", "apply_bias", "0")} -// `K` is declared only to keep the spec-constant layout aligned with the GEMM -// shaders so both variants can share a single DynamicDispatchNode with a -// runtime shader picker. It is not referenced in the GEMV body — the local -// `K` (derived from `input_sizes.x`) shadows it inside main(). -${layout_declare_spec_const(C, "int", "K", "1024")} -// Quantization group size in elements. `blocks_per_group` (the original GEMV -// spec constant) is recomputed from `group_size` below since K_PER_STEP = 32. -${layout_declare_spec_const(C, "int", "group_size", "32")} - -// Inter-wave reduction buffer (NUM_SUBGROUPS - 1 slabs of LWG_X_SIZE vec2). -// Slots are addressed by x-thread index, not subgroup lane index — sized to -// LWG.x so the shader is portable across subgroup widths. -shared vec2 partial_sums[LWG_X_SIZE * (NUM_SUBGROUPS - 1)]; - -$if not USE_SUBGROUP_BROADCAST: - // Used by the texture-storage write path to swap acc with the n2-XOR-1 - // partner thread. Replaces subgroupShuffleXor in the nosg variant. - shared vec2 nosg_n2_partner[LWG_X_SIZE]; - -// Load a vec4 of activations from input at (vec4 index) idx. -vec4 load_input_vec4(const int idx) { -#ifdef IO_BUFFER - return vec4(t_fp_input[idx]); -#else - return vec4(texelFetch(t_fp_input, ivec3(idx, 0, 0), 0)); -#endif -} - -// Load 2 scales for (n2, group) directly as a gvec2. -// The scale prepack bytes are reinterpreted as gvec2[group_idx * N2 + n2] -// where gvec2 is vec2 (fp32) or f16vec2 (fp16). The vec2(...) cast is a no-op -// for fp32 and an f16 -> f32 widening for fp16. -vec2 load_scale_pair(const int n2, const int group_idx, const int N2) { - return vec2(t_scales[group_idx * N2 + n2]); -} - -void main() { - $if USE_SUBGROUP_BROADCAST: - // sg path: lane_id == subgroup invocation; relies on subgroupSize == LWG.x - // (=64) so subgroup lane and x-thread coincide for shared-mem indexing. - const uint lane_id = gl_SubgroupInvocationID; - $else: - // nosg path: lane_id == x-thread within workgroup; portable across any - // subgroup width since shared-mem slots are addressed purely by LWG.x. - const uint lane_id = gl_LocalInvocationID.x; - const int k_wave_id = int(gl_LocalInvocationID.y); - const int n2 = int(gl_GlobalInvocationID.x); - - const int N = output_sizes.x; - const int K = input_sizes.x; - const int N2 = N / 2; - const int num_steps = K / K_PER_STEP; - // Derived from the shared `group_size` spec constant. Each K_PER_STEP (=32) - // K-block is one "block"; blocks_per_group tells how many consecutive blocks - // share a single scale pair along K. - const int blocks_per_group = group_size / K_PER_STEP; - - // Words per K-block in the weight buffer. One K-block covers K_PER_STEP K-vals - // = K_PER_STEP/4 k4 slices, and each k4 slice is N2 word-pairs. So - // words_per_k_block = (K_PER_STEP / 4) * N2. - // `k * K_BLOCK_STRIDE_W` gives the absolute word offset to the start of - // K-block `k`. - const int K_BLOCK_STRIDE_W = (K_PER_STEP / 4) * N2; - - if (n2 >= N2) { - return; - } - - vec2 acc = vec2(0.0); - - // Loop over k-blocks, waves split k-blocks (k_wave_id, k_wave_id+NUM_SUBGROUPS, ...). - for (int k = k_wave_id; k < num_steps; k += NUM_SUBGROUPS) { - // --- Load scale pair for this (n2, group) --- - const int group_idx = k / blocks_per_group; - vec2 scale_pair = load_scale_pair(n2, group_idx, N2); - float scale0 = scale_pair.x; - float scale1 = scale_pair.y; - - $if USE_SUBGROUP_BROADCAST: - // --- Load 8 activations per participating lane (only lanes 0..3) --- - // Lanes 0..3 each load 2 vec4s; other lanes receive via subgroupBroadcast - // in the dequant loops below. - vec4 in_vecs[2] = vec4[2](vec4(0.0), vec4(0.0)); - if (lane_id < 4u) { - const int vec4_base = k * 8 + int(lane_id) * 2; - in_vecs[0] = load_input_vec4(vec4_base); - in_vecs[1] = load_input_vec4(vec4_base + 1); - } - $else: - // --- Load all 8 activation vec4s per thread (no subgroup broadcast) --- - // Each thread independently reads the 32 activations (8 vec4) for this - // k-block. All lanes hit the same addresses, so L1 serves ~1 load from - // DRAM per unique address across the wave. - vec4 in_vecs[8]; - const int vec4_base = k * 8; - [[unroll]] for (int i = 0; i < 8; ++i) { - in_vecs[i] = load_input_vec4(vec4_base + i); - } - - // --- Load 4 int32s for the "hi" half (K positions 0..15) --- - int w_pack0 = t_q4_weights[n2 + k * K_BLOCK_STRIDE_W + N2 * 0]; - int w_pack1 = t_q4_weights[n2 + k * K_BLOCK_STRIDE_W + N2 * 1]; - int w_pack2 = t_q4_weights[n2 + k * K_BLOCK_STRIDE_W + N2 * 2]; - int w_pack3 = t_q4_weights[n2 + k * K_BLOCK_STRIDE_W + N2 * 3]; - - // --- Dequant + accumulate for the "hi" block (K = 0..15 of this K_PER_STEP). --- - // Each regA word contains interleaved byte pairs: byte b = (N_even, K=b) - // in the low nibble, (N_odd, K=b) in the high nibble. - float in_val; - - [[unroll]] for (int k4i = 0; k4i < 4; ++k4i) { - $if USE_SUBGROUP_BROADCAST: - in_val = subgroupBroadcast(in_vecs[0][k4i], 0u); - $else: - in_val = in_vecs[0][k4i]; - acc.x += (float(int((uint(w_pack0) >> (8 * k4i)) & 0xFu)) - 8.0) * scale0 * in_val; - acc.y += (float(int((uint(w_pack0) >> (8 * k4i + 4)) & 0xFu)) - 8.0) * scale1 * in_val; - } - - [[unroll]] for (int k4i = 0; k4i < 4; ++k4i) { - $if USE_SUBGROUP_BROADCAST: - in_val = subgroupBroadcast(in_vecs[1][k4i], 0u); - $else: - in_val = in_vecs[1][k4i]; - acc.x += (float(int((uint(w_pack1) >> (8 * k4i)) & 0xFu)) - 8.0) * scale0 * in_val; - acc.y += (float(int((uint(w_pack1) >> (8 * k4i + 4)) & 0xFu)) - 8.0) * scale1 * in_val; - } - - [[unroll]] for (int k4i = 0; k4i < 4; ++k4i) { - $if USE_SUBGROUP_BROADCAST: - in_val = subgroupBroadcast(in_vecs[0][k4i], 1u); - $else: - in_val = in_vecs[2][k4i]; - acc.x += (float(int((uint(w_pack2) >> (8 * k4i)) & 0xFu)) - 8.0) * scale0 * in_val; - acc.y += (float(int((uint(w_pack2) >> (8 * k4i + 4)) & 0xFu)) - 8.0) * scale1 * in_val; - } - - [[unroll]] for (int k4i = 0; k4i < 4; ++k4i) { - $if USE_SUBGROUP_BROADCAST: - in_val = subgroupBroadcast(in_vecs[1][k4i], 1u); - $else: - in_val = in_vecs[3][k4i]; - acc.x += (float(int((uint(w_pack3) >> (8 * k4i)) & 0xFu)) - 8.0) * scale0 * in_val; - acc.y += (float(int((uint(w_pack3) >> (8 * k4i + 4)) & 0xFu)) - 8.0) * scale1 * in_val; - } - - // --- Load 4 int32s for the "lo" half (K positions 16..31). --- - w_pack0 = t_q4_weights[n2 + k * K_BLOCK_STRIDE_W + N2 * 4]; - w_pack1 = t_q4_weights[n2 + k * K_BLOCK_STRIDE_W + N2 * 5]; - w_pack2 = t_q4_weights[n2 + k * K_BLOCK_STRIDE_W + N2 * 6]; - w_pack3 = t_q4_weights[n2 + k * K_BLOCK_STRIDE_W + N2 * 7]; - - // --- Dequant + accumulate for the "lo" block (K = 16..31). --- - [[unroll]] for (int k4i = 0; k4i < 4; ++k4i) { - $if USE_SUBGROUP_BROADCAST: - in_val = subgroupBroadcast(in_vecs[0][k4i], 2u); - $else: - in_val = in_vecs[4][k4i]; - acc.x += (float(int((uint(w_pack0) >> (8 * k4i)) & 0xFu)) - 8.0) * scale0 * in_val; - acc.y += (float(int((uint(w_pack0) >> (8 * k4i + 4)) & 0xFu)) - 8.0) * scale1 * in_val; - } - - [[unroll]] for (int k4i = 0; k4i < 4; ++k4i) { - $if USE_SUBGROUP_BROADCAST: - in_val = subgroupBroadcast(in_vecs[1][k4i], 2u); - $else: - in_val = in_vecs[5][k4i]; - acc.x += (float(int((uint(w_pack1) >> (8 * k4i)) & 0xFu)) - 8.0) * scale0 * in_val; - acc.y += (float(int((uint(w_pack1) >> (8 * k4i + 4)) & 0xFu)) - 8.0) * scale1 * in_val; - } - - [[unroll]] for (int k4i = 0; k4i < 4; ++k4i) { - $if USE_SUBGROUP_BROADCAST: - in_val = subgroupBroadcast(in_vecs[0][k4i], 3u); - $else: - in_val = in_vecs[6][k4i]; - acc.x += (float(int((uint(w_pack2) >> (8 * k4i)) & 0xFu)) - 8.0) * scale0 * in_val; - acc.y += (float(int((uint(w_pack2) >> (8 * k4i + 4)) & 0xFu)) - 8.0) * scale1 * in_val; - } - - [[unroll]] for (int k4i = 0; k4i < 4; ++k4i) { - $if USE_SUBGROUP_BROADCAST: - in_val = subgroupBroadcast(in_vecs[1][k4i], 3u); - $else: - in_val = in_vecs[7][k4i]; - acc.x += (float(int((uint(w_pack3) >> (8 * k4i)) & 0xFu)) - 8.0) * scale0 * in_val; - acc.y += (float(int((uint(w_pack3) >> (8 * k4i + 4)) & 0xFu)) - 8.0) * scale1 * in_val; - } - } - - // --- Inter-wave reduction via flat shared memory (matches OpenCL) --- - if (k_wave_id >= 1) { - partial_sums[(k_wave_id - 1) * LWG_X_SIZE + int(lane_id)] = acc; - } - barrier(); - if (k_wave_id == 0) { - [[unroll]] for (int w = 0; w < NUM_SUBGROUPS - 1; ++w) { - acc += partial_sums[w * LWG_X_SIZE + int(lane_id)]; - } - - // Apply bias if present - if (apply_bias > 0) { - acc.x += float(t_bias[n2 * 2]); - acc.y += float(t_bias[n2 * 2 + 1]); - } - } - - // --- Write 2 outputs --- -#ifdef IO_BUFFER - if (k_wave_id == 0) { - t_output[n2 * 2] = T(acc.x); - t_output[n2 * 2 + 1] = T(acc.y); - } -#else - // texture3d: output stored as width-packed vec4 at (n4, 0, 0). - // Each thread owns 2 outputs (n2*2, n2*2+1). Two consecutive n2s share - // one vec4; only the even-n2 thread assembles and writes the full vec4. - $if USE_SUBGROUP_BROADCAST: - vec2 partner = vec2( - subgroupShuffleXor(acc.x, 1u), - subgroupShuffleXor(acc.y, 1u)); - if (k_wave_id == 0 && (n2 & 1) == 0) { - vec4 out_vec; - out_vec.xy = acc; - out_vec.zw = partner; - const int n4 = n2 / 2; - imageStore(t_output, ivec3(n4, 0, 0), out_vec); - } - $else: - // Subgroup-free partner exchange via shared memory. Only k_wave_id==0 - // threads have a valid reduced `acc`, so only those threads write the - // partner slot; all threads must reach the barrier (uniform control - // flow). Then the even-n2 k_wave_id==0 threads read the n2-XOR-1 - // partner slot and assemble the output vec4. A barrier before the - // write resynchronizes after the inter-wave reduction read of - // partial_sums (which conflicts with the partner-exchange shared - // memory only conceptually — they are separate arrays — but the - // pre-barrier matches the OpenCL reference style and is cheap). - barrier(); - if (k_wave_id == 0) { - nosg_n2_partner[lane_id] = acc; - } - barrier(); - if (k_wave_id == 0 && (n2 & 1) == 0) { - vec2 partner = nosg_n2_partner[lane_id ^ 1u]; - vec4 out_vec; - out_vec.xy = acc; - out_vec.zw = partner; - const int n4 = n2 / 2; - imageStore(t_output, ivec3(n4, 0, 0), out_vec); - } -#endif -} diff --git a/backends/vulkan/test/ops/benchmark/test_ops/glsl/q4gsw_linear_gemv__w_4x8.yaml b/backends/vulkan/test/ops/benchmark/test_ops/glsl/q4gsw_linear_gemv__w_4x8.yaml deleted file mode 100644 index d5e853d2aed..00000000000 --- a/backends/vulkan/test/ops/benchmark/test_ops/glsl/q4gsw_linear_gemv__w_4x8.yaml +++ /dev/null @@ -1,40 +0,0 @@ -# Copyright (c) Meta Platforms, Inc. and affiliates. -# All rights reserved. -# -# This source code is licensed under the BSD-style license found in the -# LICENSE file in the root directory of this source tree. - -q4gsw_linear_gemv__w_4x8: - parameter_names_with_default_values: - DTYPE: float - IO_STORAGE: buffer - NUM_SUBGROUPS: 4 - SUBGROUP_SIZE: 64 - LWG_X_SIZE: 64 - USE_SUBGROUP_BROADCAST: true - WEIGHT_TILE_CONTIG_DIM: 0 - generate_variant_forall: - IO_STORAGE: - - VALUE: buffer - - VALUE: texture3d - DTYPE: - - VALUE: float - - VALUE: half - shader_variants: - # interleaved nibble, N dim contiguous, subgroup broadcast. - # SUBGROUP_SIZE=64 (inherited from top-level) auto-pins the pipeline's - # required subgroup size to 64 so subgroupBroadcast indexing matches the - # host-side LWG.x assumption (currently 64). Without this the driver - # may pick any size in [minSubgroupSize, maxSubgroupSize] (e.g. 128 on - # Adreno 750) and break the reduction. - - NAME: q4gsw_linear_gemv__w_4x8_nc - WEIGHT_TILE_CONTIG_DIM: 0 - # interleaved nibble, N dim contiguous, no subgroup ops — portable - # across any subgroup width (works on Mali / 16-wide as well as Adreno). - # SUBGROUP_SIZE=0 opts out of the pipeline subgroup-size pin (the - # SUBGROUP_SIZE macro is unused on the nosg path; shared-mem layout uses - # LWG_X_SIZE). - - NAME: q4gsw_linear_gemv__w_4x8_nc_nosg - WEIGHT_TILE_CONTIG_DIM: 0 - USE_SUBGROUP_BROADCAST: false - SUBGROUP_SIZE: 0 diff --git a/backends/vulkan/test/ops/benchmark/test_ops/impl/AddPrototype.cpp b/backends/vulkan/test/ops/benchmark/test_ops/impl/AddPrototype.cpp deleted file mode 100644 index a59cd1ba197..00000000000 --- a/backends/vulkan/test/ops/benchmark/test_ops/impl/AddPrototype.cpp +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. - */ - -#include - -#include -#include - -namespace vkcompute { - -// Shader selection function for add operations -vkapi::ShaderInfo pick_add_shader( - ComputeGraph* graph, - const std::vector& args, - const std::vector& resize_args) { - (void)resize_args; - const ValueRef out = args.at(0).refs.at(0); - const ValueRef in1 = args.at(1).refs.at(0); - - // Build shader name following the binary_op pattern - std::string kernel_name = "add"; - add_storage_type_suffix(kernel_name, graph->storage_type_of(out)); - add_dtype_suffix(kernel_name, graph->dtype_of(in1)); - - return VK_KERNEL_FROM_STR(kernel_name); -} - -// Global workgroup size function for add operations -GlobalWorkGrid add_gwg( - ComputeGraph* graph, - const vkapi::ShaderInfo& shader, - const std::vector& args, - const std::vector& resize_args) { - return default_pick_gwg(graph, shader, args, resize_args); -} - -// Local workgroup size function for add operations -LocalWorkGroup add_lwg( - ComputeGraph* graph, - const vkapi::ShaderInfo& shader, - const GlobalWorkGrid& gwg, - const std::vector& args, - const std::vector& resize_args) { - return default_pick_lwg(graph, shader, gwg, args, resize_args); -} - -void add_prototype(ComputeGraph& graph, const std::vector& args) { - int idx = 0; - const ValueRef input_a = args.at(idx++); - const ValueRef input_b = args.at(idx++); - const ValueRef output = args.at(idx++); - - // Prepare parameter buffers (empty for add operation) - vkapi::ParamsBindList param_buffers; - - // Prepare push constants based on storage type - std::vector push_constants; - push_constants.reserve(graph.is_buffer_storage(output) ? 1 : 1); - - if (graph.is_buffer_storage(output)) { - // Buffer storage: pass numel as push constant - push_constants.emplace_back(graph.numel_pc_of(output)); - } else { - // Texture storage: pass sizes as push constant - push_constants.emplace_back(graph.sizes_pc_of(output)); - } - - // Prepare specialization constants - vkapi::SpecVarList spec_vars; - if (graph.is_buffer_storage(output)) { - spec_vars = { - graph.hashed_layout_of(output), - graph.hashed_layout_of(input_a), - graph.hashed_layout_of(input_b)}; - } else { - spec_vars = {graph.hashed_layout_of(output)}; - } - - // Add the compute node - graph.execute_nodes().emplace_back(new DynamicDispatchNode( - graph, - pick_add_shader, - add_gwg, - add_lwg, - // Inputs and Outputs - {{output, vkapi::kWrite}, {{input_a, input_b}, vkapi::kRead}}, - // Shader params buffers - param_buffers, - // Push Constants - push_constants, - // Specialization Constants - spec_vars, - // Resize args - {}, - // Resizing Logic - nullptr)); -} - -REGISTER_OPERATORS { - VK_REGISTER_OP(etvk.add_prototype, add_prototype); -} - -} // namespace vkcompute diff --git a/backends/vulkan/test/ops/benchmark/test_ops/impl/TestConv1dDW.cpp b/backends/vulkan/test/ops/benchmark/test_ops/impl/TestConv1dDW.cpp deleted file mode 100644 index 15923462a20..00000000000 --- a/backends/vulkan/test/ops/benchmark/test_ops/impl/TestConv1dDW.cpp +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. - */ - -#include - -#include - -namespace vkcompute { - -void test_conv1d_dw(ComputeGraph& graph, const std::vector& args) { - // args: in, weight, bias, stride, padding, dilation, groups, out - const ValueRef input = args.at(0); - const ValueRef weight = args.at(1); - const ValueRef bias = args.at(2); - const ValueRef stride = args.at(3); - const ValueRef padding = args.at(4); - const ValueRef dilation = args.at(5); - const ValueRef groups = args.at(6); - const ValueRef out = args.at(7); - - // conv1d_dw expects: in, weight, bias, stride, padding, dilation, groups, - // output_min, output_max, out - VK_GET_OP_FN("et_vk.conv1d_dw.default") - (graph, - {input, - weight, - bias, - stride, - padding, - dilation, - groups, - kDummyValueRef, - kDummyValueRef, - out}); -} - -REGISTER_OPERATORS { - VK_REGISTER_OP(test_etvk.test_conv1d_dw.default, test_conv1d_dw); -} - -} // namespace vkcompute diff --git a/backends/vulkan/test/ops/benchmark/test_ops/impl/TestConv1dPW.cpp b/backends/vulkan/test/ops/benchmark/test_ops/impl/TestConv1dPW.cpp deleted file mode 100644 index 99ccf082b1e..00000000000 --- a/backends/vulkan/test/ops/benchmark/test_ops/impl/TestConv1dPW.cpp +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. - */ - -#include - -#include - -namespace vkcompute { - -void test_conv1d_pw(ComputeGraph& graph, const std::vector& args) { - // args: in, weight, bias, stride, padding, dilation, groups, out - const ValueRef input = args.at(0); - const ValueRef weight = args.at(1); - const ValueRef bias = args.at(2); - const ValueRef stride = args.at(3); - const ValueRef padding = args.at(4); - const ValueRef dilation = args.at(5); - const ValueRef groups = args.at(6); - const ValueRef out = args.at(7); - - // conv1d_pw expects: in, weight, bias, stride, padding, dilation, groups, - // output_min, output_max, out - VK_GET_OP_FN("et_vk.conv1d_pw.default") - (graph, - {input, - weight, - bias, - stride, - padding, - dilation, - groups, - kDummyValueRef, - kDummyValueRef, - out}); -} - -REGISTER_OPERATORS { - VK_REGISTER_OP(test_etvk.test_conv1d_pw.default, test_conv1d_pw); -} - -} // namespace vkcompute diff --git a/backends/vulkan/test/ops/benchmark/test_ops/impl/TestConv2d.cpp b/backends/vulkan/test/ops/benchmark/test_ops/impl/TestConv2d.cpp deleted file mode 100644 index 5e50c3f8f0a..00000000000 --- a/backends/vulkan/test/ops/benchmark/test_ops/impl/TestConv2d.cpp +++ /dev/null @@ -1,167 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. - */ - -#include - -#include -#include -#include - -#include - -namespace vkcompute { - -void test_conv2d(ComputeGraph& graph, const std::vector& args) { - // args[0] = input [N, C_in, H, W] - // args[1] = weight [C_out, C_in, K_h, K_w] (constant) - // args[2] = bias (constant, or none) - // args[3] = stride_h (int) - // args[4] = stride_w (int) - // args[5] = padding_h (int) - // args[6] = padding_w (int) - // args[7] = dilation_h (int) - // args[8] = dilation_w (int) - // args[9] = impl_selector (string) - // args[10] = groups (int) - // args[11] = clamp_out (bool) - // args[12] = out_min (float) - // args[13] = out_max (float) - // args[14] = output [N, C_out, H_out, W_out] - // - // impl_selector grammar: - // "" -> aten.convolution.default (heuristic-routed: - // should_use_conv2d_im2col() picks direct vs im2col) - // "direct" -> production direct picker, bypassing im2col routing - // "direct_masked" -> force bounds checking instead of zero-border sampling - // "im2col" -> et_vk.conv2d_gemm.default, auto im2col storage - // "im2col_buffer"-> im2col/GEMM, force buffer im2col intermediate - // "im2col_tex2d" -> im2col/GEMM, force texture2d im2col intermediate - // "im2col_tex3d" -> im2col/GEMM, force texture3d im2col intermediate - const ValueRef input = args.at(0); - const ValueRef weight = args.at(1); - const ValueRef bias = args.at(2); - const int64_t stride_h = graph.extract_scalar(args.at(3)); - const int64_t stride_w = graph.extract_scalar(args.at(4)); - const int64_t padding_h = graph.extract_scalar(args.at(5)); - const int64_t padding_w = graph.extract_scalar(args.at(6)); - const int64_t dilation_h = graph.extract_scalar(args.at(7)); - const int64_t dilation_w = graph.extract_scalar(args.at(8)); - const std::string impl_selector = graph.extract_string(args.at(9)); - const ValueRef groups = args.at(10); - const bool clamp_out = graph.extract_scalar(args.at(11)); - const float out_min = graph.extract_scalar(args.at(12)); - const float out_max = graph.extract_scalar(args.at(13)); - const ValueRef out = args.at(14); - if (impl_selector.find("im2col") == 0) { - VK_CHECK_COND( - graph.get_int(groups) == 1, - "im2col test selectors require groups == 1"); - } - - ValueRef stride = - graph.add_scalar_list(std::vector{stride_h, stride_w}); - ValueRef padding = graph.add_scalar_list( - std::vector{padding_h, padding_w}); - ValueRef dilation = graph.add_scalar_list( - std::vector{dilation_h, dilation_w}); - - // The forced-storage variants must reach conv2d_gemm_impl with the override, - // which the registered op (et_vk.conv2d_gemm.default) cannot express since it - // always auto-selects. Route those directly to conv2d_gemm_impl; the auto - // ("im2col") and direct ("") paths stay on the registered-op dispatch. - std::optional im2col_storage_override; - if (impl_selector == "im2col_buffer") { - im2col_storage_override = utils::kBuffer; - } else if (impl_selector == "im2col_tex2d") { - im2col_storage_override = utils::kTexture2D; - } else if (impl_selector == "im2col_tex3d") { - im2col_storage_override = utils::kTexture3D; - } - - if (im2col_storage_override.has_value()) { - conv2d_gemm_impl( - graph, - input, - weight, - bias, - stride, - padding, - dilation, - out, - clamp_out, - out_min, - out_max, - im2col_storage_override); - return; - } - - ValueRef transposed = graph.add_scalar(false); - ValueRef output_padding = - graph.add_scalar_list(std::vector{0, 0}); - - if (impl_selector == "direct" || impl_selector == "direct_masked") { - conv2d_direct_impl( - graph, - input, - weight, - bias, - stride, - padding, - dilation, - transposed, - output_padding, - groups, - out, - clamp_out, - out_min, - out_max, - /*force_masked=*/impl_selector == "direct_masked"); - return; - } - - if (impl_selector == "im2col") { - conv2d_gemm_impl( - graph, - input, - weight, - bias, - stride, - padding, - dilation, - out, - clamp_out, - out_min, - out_max); - return; - } - const std::string target_op = - clamp_out ? "et_vk.conv_with_clamp.default" : "aten.convolution.default"; - - std::vector op_args = { - input, - weight, - bias, - stride, - padding, - dilation, - transposed, - output_padding, - groups}; - if (clamp_out) { - op_args.push_back(args.at(12)); - op_args.push_back(args.at(13)); - } - op_args.push_back(out); - VK_GET_OP_FN(target_op.c_str())(graph, op_args); -} - -REGISTER_OPERATORS { - VK_REGISTER_OP(test_etvk.test_conv2d.default, test_conv2d); -} - -} // namespace vkcompute diff --git a/backends/vulkan/test/ops/benchmark/test_ops/impl/TestConv2dDw.cpp b/backends/vulkan/test/ops/benchmark/test_ops/impl/TestConv2dDw.cpp deleted file mode 100644 index a9e26be9454..00000000000 --- a/backends/vulkan/test/ops/benchmark/test_ops/impl/TestConv2dDw.cpp +++ /dev/null @@ -1,363 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. - */ - -#include - -#include -#include -#include -#include -#include -#include - -namespace vkcompute { - -// -// Local copies of Conv2dDW internals, extended with impl_selector support. -// These mirror the logic in Conv2dDW.cpp but allow forcing a specific tile size -// variant via the impl_selector string. -// - -struct Conv2dDWParams final { - utils::ivec2 overlay_region; - int in_group_size; -}; - -struct OutputParams final { - float out_min; - float out_max; -}; - -static std::string pick_conv2d_dw_shader_with_selector( - ComputeGraph& graph, - const ValueRef weight_data, - const ValueRef out, - const bool stride_equals_dilation, - const bool clamp_out, - const std::string& impl_selector) { - std::string kernel_name = "conv2d_dw"; - kernel_name.reserve(40); - - const auto& weight_sizes = graph.get_tref(weight_data)->sizes; - const bool is_3x3 = weight_sizes.at(2) == 3 && weight_sizes.at(3) == 3; - const bool is_5x5 = weight_sizes.at(2) == 5 && weight_sizes.at(3) == 5; - - // Only the tiled 3x3 / 5x5 shaders have a stride != dilation (_sned) - // variant. The generic conv2d_dw shader reads stride and dilation from its - // params and handles any combination, so it must not take the infix: - // conv2d_dw_sned_ does not exist and the lookup throws at load. - if (!stride_equals_dilation && (is_3x3 || is_5x5)) { - kernel_name += "_sned"; - } - - if (is_3x3) { - kernel_name += "_output_tile_3x3"; - if (impl_selector == "b1x1") { - // The _b1x1 batch-tile variant exists only for the non-sned family; - // sned (stride != dilation) shaders are not batch-tiled. Match - // pick_conv2d_dw_shader and only append it when stride == dilation, - // otherwise fall back to the un-suffixed sned shader. - if (stride_equals_dilation) { - kernel_name += "_b1x1"; - } - } else if (impl_selector == "b4x2") { - // b4x2 is the default (no suffix) - } else { - // Auto-selection: use b1x1 on Mali - if (stride_equals_dilation && graph.device_is_mali()) { - kernel_name += "_b1x1"; - } - } - } else if (is_5x5) { - kernel_name += "_output_tile_5x5"; - // No b1x1 variant for 5x5; impl_selector is ignored for batch size - } - - if (clamp_out) { - kernel_name += "_clamp"; - } - add_dtype_suffix(kernel_name, graph.dtype_of(out)); - - return kernel_name; -} - -static GlobalWorkGrid conv2d_dw_gwg_fn( - ComputeGraph* graph, - const vkapi::ShaderInfo& shader, - const std::vector& args, - const std::vector& resize_args) { - (void)resize_args; - const ValueRef out = args.at(0).refs.at(0); - - const bool uses_output_tile = - shader.kernel_name.find("_output_tile") != std::string::npos; - - if (uses_output_tile) { - const bool is_sned = shader.kernel_name.find("_sned") != std::string::npos; - const utils::uvec3 image_extents = graph->create_gwg(out).extents(); - - if (is_sned) { - return GlobalWorkGrid( - {image_extents[0] * image_extents[1], image_extents[2], 1u}, - kTiledWorkGrid); - } - - uint32_t batch_x = 4u; - uint32_t batch_y = 2u; - if (shader.kernel_name.find("_b1x1") != std::string::npos) { - batch_x = 1u; - batch_y = 1u; - } - - uint32_t scaled_x = utils::div_up(image_extents[0], batch_x); - uint32_t scaled_y = utils::div_up(image_extents[1], batch_y); - return GlobalWorkGrid( - {scaled_x * scaled_y, image_extents[2], 1u}, kTiledWorkGrid); - } - - const utils::uvec3 base_extents = graph->create_gwg(out).extents(); - return graph->create_linear_gwg( - base_extents[0] * base_extents[1] * base_extents[2]); -} - -static LocalWorkGroup conv2d_dw_lwg_fn( - ComputeGraph* graph, - const vkapi::ShaderInfo& shader, - const GlobalWorkGrid& gwg, - const std::vector& args, - const std::vector& resize_args) { - (void)graph; - (void)shader; - (void)gwg; - (void)args; - (void)resize_args; - return LocalWorkGroup(64u, 1u, 1u); -} - -static ValueRef prepack_dw_weights(ComputeGraph& graph, const ValueRef vref) { - const auto original_sizes = graph.sizes_of(vref); - - int64_t out_channels_padded = - utils::align_up_4(utils::val_at(-4, original_sizes)); - int64_t height = utils::val_at(-2, original_sizes); - int64_t width = utils::val_at(-1, original_sizes); - - const std::vector final_sizes = { - 4, out_channels_padded / 4, height * width}; - - ValueRef v = graph.add_tensor( - final_sizes, - graph.dtype_of(vref), - utils::kTexture2D, - utils::kChannelsPacked); - - std::string kernel_name = "conv2d_dw_prepack_weights"; - add_dtype_suffix(kernel_name, graph.dtype_of(v)); - add_dtype_suffix(kernel_name, graph.get_staging_dtype_for(vref)); - - const auto original_sizes_pc = - utils::make_ivec4(original_sizes, /*reverse=*/true); - graph.prepack_nodes().emplace_back(new PrepackNode( - graph, - VK_KERNEL_FROM_STR(kernel_name), - graph.create_gwg(v), - graph.create_lwg(v), - vref, - v, - {}, - {graph.packed_dim_of(v)}, - {graph.sizes_pc_of(v), - PushConstantDataInfo(&original_sizes_pc, sizeof(original_sizes_pc))})); - - return v; -} - -static void conv2d_dw_with_selector( - ComputeGraph& graph, - const ValueRef in, - const ValueRef weight_data, - const ValueRef bias, - const ValueRef stride, - const ValueRef padding, - const ValueRef dilation, - const ValueRef out, - const std::string& impl_selector) { - ValueRef arg_weight = prepack_dw_weights(graph, weight_data); - ValueRef arg_bias = prepack_biases( - graph, - bias, - weight_data, - /*transposed=*/false, - /*storage_type=*/utils::kTexture2D, - /*memory_layout=*/utils::kWidthPacked); - - check_conv_args(graph, in, out); - - Kernel2dParams kernel_params = create_kernel2d_params( - graph, - weight_data, - /*kernel_size_only=*/false, - stride, - padding, - dilation); - - const bool stride_equals_dilation = - (kernel_params.stride[0] == kernel_params.dilation[0] && - kernel_params.stride[1] == kernel_params.dilation[1]); - - const auto& overlay_region = utils::make_ivec2({ - kernel_params.kernel_size[0] + - (kernel_params.kernel_size[0] - 1) * (kernel_params.dilation[0] - 1), - kernel_params.kernel_size[1] + - (kernel_params.kernel_size[1] - 1) * (kernel_params.dilation[1] - 1), - }); - const auto weight_sizes = graph.sizes_of(weight_data); - const int32_t in_group_size = - utils::safe_downcast(utils::align_up_4(weight_sizes.at(1))); - Conv2dDWParams extra_params = {overlay_region, in_group_size}; - - OutputParams out_params = { - std::numeric_limits::lowest(), std::numeric_limits::max()}; - - std::string kernel_name = pick_conv2d_dw_shader_with_selector( - graph, - weight_data, - out, - stride_equals_dilation, - /*clamp_out=*/false, - impl_selector); - - vkapi::ShaderInfo shader = VK_KERNEL_FROM_STR(kernel_name); - - vkapi::ParamsBindList param_buffers; - std::vector push_constants; - - const bool uses_output_tile = - kernel_name.find("_output_tile") != std::string::npos; - - if (uses_output_tile) { - const utils::ivec4 kernel_param_size_stride = { - kernel_params.kernel_size[0], - kernel_params.kernel_size[1], - kernel_params.stride[0], - kernel_params.stride[1]}; - - const utils::ivec4 kernel_param_pad_dial = { - kernel_params.padding[0], - kernel_params.padding[1], - kernel_params.dilation[0], - kernel_params.dilation[1]}; - - push_constants = { - graph.logical_limits_pc_of(out), - graph.sizes_pc_of(in), - PushConstantDataInfo( - &kernel_param_size_stride, sizeof(kernel_param_size_stride)), - PushConstantDataInfo( - &kernel_param_pad_dial, sizeof(kernel_param_pad_dial)), - PushConstantDataInfo( - &extra_params, sizeof(extra_params), sizeof(utils::ivec4)), - PushConstantDataInfo(&out_params, sizeof(out_params)), - }; - } else { - param_buffers = { - graph.logical_limits_ubo(out), - graph.sizes_ubo(in), - graph.create_params_buffer(kernel_params), - graph.create_params_buffer(extra_params), - graph.create_params_buffer(out_params), - }; - } - - ValueRef transposed_ref = graph.add_scalar(false); - ValueRef output_padding = graph.add_none(); - - graph.execute_nodes().emplace_back(new DynamicDispatchNode( - graph, - shader, - conv2d_dw_gwg_fn, - conv2d_dw_lwg_fn, - {{out, vkapi::kWrite}, {{in, arg_weight, arg_bias}, vkapi::kRead}}, - param_buffers, - push_constants, - {}, - {weight_data, stride, padding, dilation, transposed_ref, output_padding}, - resize_conv2d_node)); -} - -void test_conv2d_dw(ComputeGraph& graph, const std::vector& args) { - // args[0] = input [N, C, H, W] - // args[1] = weight [C, 1, K_h, K_w] (constant) - // args[2] = bias (constant, or none) - // args[3] = stride_h (int) - // args[4] = stride_w (int) - // args[5] = padding_h (int) - // args[6] = padding_w (int) - // args[7] = dilation_h (int) - // args[8] = dilation_w (int) - // args[9] = impl_selector (string) - // args[10] = output - const ValueRef input = args.at(0); - const ValueRef weight = args.at(1); - const ValueRef bias = args.at(2); - const int64_t stride_h = graph.extract_scalar(args.at(3)); - const int64_t stride_w = graph.extract_scalar(args.at(4)); - const int64_t padding_h = graph.extract_scalar(args.at(5)); - const int64_t padding_w = graph.extract_scalar(args.at(6)); - const int64_t dilation_h = graph.extract_scalar(args.at(7)); - const int64_t dilation_w = graph.extract_scalar(args.at(8)); - const std::string impl_selector = graph.extract_string(args.at(9)); - const ValueRef out = args.at(10); - - ValueRef stride = - graph.add_scalar_list(std::vector{stride_h, stride_w}); - ValueRef padding = graph.add_scalar_list( - std::vector{padding_h, padding_w}); - ValueRef dilation = graph.add_scalar_list( - std::vector{dilation_h, dilation_w}); - - if (impl_selector.empty()) { - // Auto-selection: delegate to aten.convolution.default - const int64_t channels = graph.sizes_of(input).at(1); - ValueRef transposed = graph.add_scalar(false); - ValueRef output_padding = - graph.add_scalar_list(std::vector{0, 0}); - ValueRef groups = graph.add_scalar(channels); - - VK_GET_OP_FN("aten.convolution.default") - (graph, - {input, - weight, - bias, - stride, - padding, - dilation, - transposed, - output_padding, - groups, - out}); - } else { - // Forced variant: build the dispatch directly with impl_selector - conv2d_dw_with_selector( - graph, - input, - weight, - bias, - stride, - padding, - dilation, - out, - impl_selector); - } -} - -REGISTER_OPERATORS { - VK_REGISTER_OP(test_etvk.test_conv2d_dw.default, test_conv2d_dw); -} - -} // namespace vkcompute diff --git a/backends/vulkan/test/ops/benchmark/test_ops/impl/TestConv2dPw.cpp b/backends/vulkan/test/ops/benchmark/test_ops/impl/TestConv2dPw.cpp deleted file mode 100644 index 7c640d7a1ac..00000000000 --- a/backends/vulkan/test/ops/benchmark/test_ops/impl/TestConv2dPw.cpp +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. - */ - -#include - -#include - -namespace vkcompute { - -void test_conv2d_pw(ComputeGraph& graph, const std::vector& args) { - // args[0] = input [N, C_in, H, W] - // args[1] = weight [C_out, C_in, 1, 1] (constant) - // args[2] = bias (constant, or none) - // args[3] = impl_selector (string) - // args[4] = output [N, C_out, H, W] - const ValueRef input = args.at(0); - const ValueRef weight = args.at(1); - const ValueRef bias = args.at(2); - const ValueRef impl_selector_str = args.at(3); - const ValueRef out = args.at(4); - - std::string impl_selector = graph.extract_string(impl_selector_str); - (void)impl_selector; // Reserved for future use - - // Create fixed pointwise conv parameters - ValueRef stride = graph.add_scalar_list(std::vector{1, 1}); - ValueRef padding = graph.add_scalar_list(std::vector{0, 0}); - ValueRef dilation = - graph.add_scalar_list(std::vector{1, 1}); - ValueRef transposed = graph.add_scalar(false); - ValueRef output_padding = - graph.add_scalar_list(std::vector{0, 0}); - ValueRef groups = graph.add_scalar(1); - - // Call aten.convolution.default with all 10 args: - // input, weight, bias, stride, padding, dilation, transposed, - // output_padding, groups, output - VK_GET_OP_FN("aten.convolution.default") - (graph, - {input, - weight, - bias, - stride, - padding, - dilation, - transposed, - output_padding, - groups, - out}); -} - -REGISTER_OPERATORS { - VK_REGISTER_OP(test_etvk.test_conv2d_pw.default, test_conv2d_pw); -} - -} // namespace vkcompute diff --git a/backends/vulkan/test/ops/benchmark/test_ops/impl/TestFpaQ4gswLinear.cpp b/backends/vulkan/test/ops/benchmark/test_ops/impl/TestFpaQ4gswLinear.cpp deleted file mode 100644 index 4b41391b2cd..00000000000 --- a/backends/vulkan/test/ops/benchmark/test_ops/impl/TestFpaQ4gswLinear.cpp +++ /dev/null @@ -1,871 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. - */ - -#include - -#include -#include -#include -#include -#include -#include - -namespace vkcompute { - -namespace { - -// File-scoped enum mirroring the previously-proposed Q4gswLinearKernelKind. -// Kept internal to the test op so that production code stays untouched. -enum class TestKernelKind { - PROD, // Dtype-based picker: fp32 -> w_4x8, fp16 -> tin_w_4x8. - GEMM_W_4X8, // Force non-tin GEMM (reads fp_input directly). - GEMM_TIN_W_4X8, // Force tin GEMM (transposed input preprocess emitted). - GEMV_W_4X8, // Force gemv with subgroup broadcast. - GEMV_W_4X8_NOSG, // Force gemv without subgroup broadcast. - LEGACY, // Legacy in-prod q4gsw linear (et_vk.linear_q4gsw.default). - GEMV_COOP_W_4X8_NC_BUFFER, // coop GEMV with nc Buffer weight (prod nc-buf - // prepack). Equivalent to the _g1w64 variant - // (NUM_GROUPS=1, WORKERS_PER_GROUP=64). - // Forced coop nc-buffer GEMV reduction-decomposition variants. The - // production picker (pick_coop_variant_for_N in Q4gswLinear.cpp) selects - // among these based on output N: N<=1024 -> g1w64, N<=4096 -> g4w16, else - // g8w8. The PERF-sized N where g4w16 / g8w8 are normally chosen exceeds - // kRefDimSizeLimit, so the reference impl is skipped there; these forced - // selectors let the same (NUM_GROUPS, WORKERS_PER_GROUP) decompositions be - // validated at small N where the reference runs. - GEMV_COOP_W_4X8_NC_BUFFER_G1W64, // NUM_GROUPS=1, WORKERS_PER_GROUP=64. - GEMV_COOP_W_4X8_NC_BUFFER_G4W16, // NUM_GROUPS=4, WORKERS_PER_GROUP=16. - GEMV_COOP_W_4X8_NC_BUFFER_G8W8, // NUM_GROUPS=8, WORKERS_PER_GROUP=8. -}; - -// Map the selector int + table (gemm vs gemv) to a TestKernelKind. -// -// is_gemv = false (gemm op): -// 0 -> PROD, 1 -> GEMM_W_4X8, 2 -> GEMM_TIN_W_4X8, 3 -> LEGACY. -// -// is_gemv = true (gemv op): -// 0 -> PROD, 1 -> GEMV_W_4X8, 2 -> GEMV_W_4X8_NOSG, 3 -> LEGACY, -// 13 -> GEMV_COOP_W_4X8_NC_BUFFER (coop GEMV reusing the production -// nc-buffer prepack — same weight format used by W_4X8 GEMM/TIN GEMM -// / sg-GEMV; tests whether a single prepack can serve both prefill -// and decode). Equivalent to the _g1w64 reduction decomposition. -// 14 -> GEMV_COOP_W_4X8_NC_BUFFER_G1W64 (force NUM_GROUPS=1, WPG=64). -// 15 -> GEMV_COOP_W_4X8_NC_BUFFER_G4W16 (force NUM_GROUPS=4, WPG=16). -// 16 -> GEMV_COOP_W_4X8_NC_BUFFER_G8W8 (force NUM_GROUPS=8, WPG=8). -// -// Selectors 14-16 pin the coop nc-buffer GEMV to an explicit reduction -// decomposition regardless of N, so the g4w16 / g8w8 variants (otherwise only -// chosen by the production picker at PERF-sized N where the reference impl is -// skipped) can be ACCU-validated at small N. The production picker behavior -// (pick_coop_variant_for_N) is unaffected — these are test-only forced paths. -// -// Selector 3 (LEGACY) dispatches the in-prod legacy linear path registered as -// et_vk.linear_q4gsw.default in QuantizedLinear.cpp. It uses a different -// prepack (pack_q4_linear_weight) and different shaders (linear_q4gsw_tiled_* -// / linear_q4gsw_coop_*); it picks GEMM vs GEMV internally based on input M. -TestKernelKind selector_to_kind(int32_t selector, bool is_gemv) { - if (is_gemv) { - switch (selector) { - case 0: - return TestKernelKind::PROD; - case 1: - return TestKernelKind::GEMV_W_4X8; - case 2: - return TestKernelKind::GEMV_W_4X8_NOSG; - case 3: - return TestKernelKind::LEGACY; - case 13: - return TestKernelKind::GEMV_COOP_W_4X8_NC_BUFFER; - case 14: - return TestKernelKind::GEMV_COOP_W_4X8_NC_BUFFER_G1W64; - case 15: - return TestKernelKind::GEMV_COOP_W_4X8_NC_BUFFER_G4W16; - case 16: - return TestKernelKind::GEMV_COOP_W_4X8_NC_BUFFER_G8W8; - default: - return TestKernelKind::PROD; - } - } - switch (selector) { - case 0: - return TestKernelKind::PROD; - case 1: - return TestKernelKind::GEMM_W_4X8; - case 2: - return TestKernelKind::GEMM_TIN_W_4X8; - case 3: - return TestKernelKind::LEGACY; - default: - return TestKernelKind::PROD; - } -} - -// Returns the fixed base kernel name for a given forced kind. -const char* forced_kind_base_name(TestKernelKind kind) { - switch (kind) { - case TestKernelKind::GEMM_W_4X8: - return "q4gsw_linear_gemm__w_4x8_nc"; - case TestKernelKind::GEMM_TIN_W_4X8: - return "q4gsw_linear_gemm__tin__w_4x8_nc"; - case TestKernelKind::GEMV_W_4X8: - return "q4gsw_linear_gemv__w_4x8_nc"; - case TestKernelKind::GEMV_W_4X8_NOSG: - return "q4gsw_linear_gemv__w_4x8_nc_nosg"; - case TestKernelKind::GEMV_COOP_W_4X8_NC_BUFFER: - case TestKernelKind::GEMV_COOP_W_4X8_NC_BUFFER_G1W64: - return "q4gsw_linear_gemv_coop__w_4x8_nc_buffer_g1w64"; - case TestKernelKind::GEMV_COOP_W_4X8_NC_BUFFER_G4W16: - return "q4gsw_linear_gemv_coop__w_4x8_nc_buffer_g4w16"; - case TestKernelKind::GEMV_COOP_W_4X8_NC_BUFFER_G8W8: - return "q4gsw_linear_gemv_coop__w_4x8_nc_buffer_g8w8"; - case TestKernelKind::PROD: - case TestKernelKind::LEGACY: - default: - return ""; - } -} - -// Build a picker that ignores M and always returns the forced shader. -// Storage + dtype suffixes are appended at dispatch time. -template -vkapi::ShaderInfo pick_forced_shader( - ComputeGraph* graph, - const std::vector& args, - const std::vector& resize_args) { - (void)resize_args; - const ValueRef out = args.at(0).refs.at(0); - std::string kernel_name = forced_kind_base_name(KIND); - add_storage_type_suffix(kernel_name, graph->storage_type_of(out)); - add_dtype_suffix(kernel_name, graph->dtype_of(out)); - return VK_KERNEL_FROM_STR(kernel_name); -} - -// Picker for the new coop kc variant. The weight is bound as a Tex2D image -// (kc dense form) but the kernel naming convention only tags IO storage; we -// therefore append only the IO (output) storage suffix + dtype. -template -vkapi::ShaderInfo pick_forced_shader_coop_kc( - ComputeGraph* graph, - const std::vector& args, - const std::vector& resize_args) { - (void)resize_args; - const ValueRef out = args.at(0).refs.at(0); - std::string kernel_name = forced_kind_base_name(KIND); - add_storage_type_suffix(kernel_name, graph->storage_type_of(out)); - add_dtype_suffix(kernel_name, graph->dtype_of(out)); - return VK_KERNEL_FROM_STR(kernel_name); -} - -// Coop GEMV NUM_GROUPS / WORKERS_PER_GROUP knobs. The chosen pair must agree -// with the bound shader variant's GLSL codegen params — the shader's shared -// memory is sized NUM_GROUPS * WORKERS_PER_GROUP and the K-loop strides by -// WORKERS_PER_GROUP, so a dispatch geometry mismatch produces wrong results. -// Templating the WG pickers on the pair lets each forced variant selector -// (g1w64 / g4w16 / g8w8) dispatch matching geometry. (NUM_GROUPS=1, -// WORKERS_PER_GROUP=64) reproduces the original dispatch (LWG=(1,1,64), one WG -// per n8 tile = 8 outputs). -// -// Global WG picker for the coop GEMV. Each WG hosts NUM_GROUPS independent -// worker groups (each producing 8 outputs); WGs along x = ceil(N8 / -// NUM_GROUPS). The framework computes num_WGs = div_up(global, local), so the -// global x-axis is set to that count directly (with local.x == 1). -template -GlobalWorkGrid pick_q4gsw_coop_gwg( - ComputeGraph* graph, - const vkapi::ShaderInfo& shader, - const std::vector& args, - const std::vector& resize_args) { - (void)shader; - (void)resize_args; - const ValueRef out = args.at(0).refs.at(0); - const std::vector out_sizes = graph->sizes_of(out); - const uint32_t N = - utils::safe_downcast(utils::val_at(-1, out_sizes)); - const uint32_t N8 = (N + 7u) / 8u; - const uint32_t wgs_along_x = utils::div_up(N8, NUM_GROUPS); - const LocalWorkGroup lwg(1u, NUM_GROUPS, WORKERS_PER_GROUP); - GlobalWorkGrid gwg({wgs_along_x * NUM_GROUPS, 1u, 1u}, kLinearWorkGrid); - gwg.wrap_linear_dispatch( - graph->context()->adapter_ptr()->max_compute_workgroup_count(), lwg); - return gwg; -} - -// Local WG picker for the coop GEMV — LWG=(1, NUM_GROUPS, WORKERS_PER_GROUP). -template -LocalWorkGroup pick_q4gsw_coop_lwg( - ComputeGraph* graph, - const vkapi::ShaderInfo& shader, - const GlobalWorkGrid& gwg, - const std::vector& args, - const std::vector& resize_args) { - (void)graph; - (void)shader; - (void)gwg; - (void)args; - (void)resize_args; - return LocalWorkGroup(1u, NUM_GROUPS, WORKERS_PER_GROUP); -} - -// Spec-constant LWG values for the q4gsw_linear_gemv__w_4x8_nc[_nosg] shaders -// (now shipped from test/ops/benchmark/test_ops/glsl/). The sg variant pins -// subgroupSize to 64 via VK_EXT_subgroup_size_control; the nosg variant uses -// shared-mem reduction so the lane count is purely an LWG choice. Both use 4 -// subgroups per workgroup. -constexpr uint32_t kGemvSubgroupSize = 64u; -constexpr uint32_t kGemvNumSubgroups = 4u; - -// WG pickers for the legacy sg/nosg GEMV shaders. Used only by test selectors -// 1 (GEMV_W_4X8) and 2 (GEMV_W_4X8_NOSG); the production dispatcher never -// references these shaders. -GlobalWorkGrid pick_q4gsw_legacy_gemv_gwg( - ComputeGraph* graph, - const vkapi::ShaderInfo& shader, - const std::vector& args, - const std::vector& resize_args) { - (void)shader; - (void)resize_args; - const ValueRef out = args.at(0).refs.at(0); - const uint32_t N = - utils::safe_downcast(utils::val_at(-1, graph->sizes_of(out))); - // Each thread owns one row-pair along x; y-dim splits K-blocks across waves. - return GlobalWorkGrid({N / 2u, kGemvNumSubgroups, 1u}, kTiledWorkGrid); -} - -LocalWorkGroup pick_q4gsw_legacy_gemv_lwg( - ComputeGraph* graph, - const vkapi::ShaderInfo& shader, - const GlobalWorkGrid& gwg, - const std::vector& args, - const std::vector& resize_args) { - (void)graph; - (void)shader; - (void)gwg; - (void)args; - (void)resize_args; - return LocalWorkGroup(kGemvSubgroupSize, kGemvNumSubgroups, 1u); -} - -// -// Legacy q4gsw linear dispatch — copy of the implementation deleted from -// runtime/graph/ops/impl/QuantizedLinear.cpp by the W_4X8 commit -// (6d1fa80b3c79). Resurrected here so selector 3 (LEGACY) exercises the legacy -// `linear_q4gsw_tiled` / `linear_q4gsw_coop` shaders + `pack_q4_linear_weight` -// prepack directly, without depending on a registered production op. Trimmed -// to the q4gsw weight-only branch (no 8-bit / no activation-quant path). -// - -void legacy_q4gsw_resize_linear_node( - ComputeGraph* graph, - const std::vector& args, - const std::vector& extra_args) { - (void)extra_args; - - ValueRef output = args.at(0).refs.at(0); - ValueRef fp_input = args.at(1).refs.at(0); - ValueRef weight_data = extra_args.at(1); - - std::vector mat1_sizes = graph->sizes_of(fp_input); - std::vector mat2_sizes = graph->sizes_of(weight_data); - - const int64_t out_cols = utils::val_at(-2, mat1_sizes); - const int64_t out_rows = utils::val_at(-2, mat2_sizes); - - std::vector new_out_sizes(3); - if (mat1_sizes.size() == 2) { - new_out_sizes.resize(2); - new_out_sizes.at(0) = out_cols; - new_out_sizes.at(1) = out_rows; - } else { - new_out_sizes.at(0) = mat1_sizes.at(0); - new_out_sizes.at(1) = out_cols; - new_out_sizes.at(2) = out_rows; - } - - graph->virtual_resize(output, new_out_sizes); -} - -GlobalWorkGrid legacy_q4gsw_gwg( - ComputeGraph* graph, - const vkapi::ShaderInfo& shader, - const std::vector& args, - const std::vector& resize_args) { - (void)resize_args; - const ValueRef out = args.at(0).refs.at(0); - - std::vector out_sizes = graph->sizes_of(out); - // width - const uint32_t N = - utils::safe_downcast(utils::val_at(-1, out_sizes)); - // height - const uint32_t M = - utils::safe_downcast(utils::val_at(-2, out_sizes)); - - // For 4-bit weights, each output tile contains 8 columns - uint32_t N_per_tile = 8; - uint32_t M_per_tile = 4; - if (shader.kernel_name.find("coop") != std::string::npos) { - M_per_tile = 1; - } - - const uint32_t num_N_tiles = utils::div_up(N, N_per_tile); - const uint32_t num_M_tiles = utils::div_up(M, M_per_tile); - - return GlobalWorkGrid({num_N_tiles, num_M_tiles, 1u}, kTiledWorkGrid); -} - -LocalWorkGroup legacy_q4gsw_lwg( - ComputeGraph* graph, - const vkapi::ShaderInfo& shader, - const GlobalWorkGrid& gwg, - const std::vector& args, - const std::vector& resize_args) { - const bool use_coop_algorithm = - shader.kernel_name.find("_coop") != std::string::npos; - - if (use_coop_algorithm) { - return LocalWorkGroup(1u, 1u, 64u); - } - return pick_xy_square_lwg(graph, shader, gwg, args, resize_args); -} - -vkapi::ShaderInfo legacy_q4gsw_pick_shader( - ComputeGraph* graph, - const std::vector& args, - const std::vector& resize_args) { - (void)resize_args; - const ValueRef output = args.at(0).refs.at(0); - const ValueRef fp_input = args.at(1).refs.at(0); - const ValueRef packed_int_weight = args.at(1).refs.at(1); - - const bool is_gemv_case = is_gemv(graph, fp_input); - - std::string kernel_name = "linear_q4gsw"; - kernel_name += is_gemv_case ? "_coop" : "_tiled"; - - add_storage_type_suffix(kernel_name, graph->storage_type_of(output)); - add_storage_type_suffix( - kernel_name, graph->storage_type_of(packed_int_weight)); - add_dtype_suffix(kernel_name, graph->dtype_of(output)); - - return VK_KERNEL_FROM_STR(kernel_name); -} - -// Legacy 4-bit weight prepack — populates the [num_blocks_N, num_blocks_K * 4] -// tensor used by linear_q4gsw_tiled / linear_q4gsw_coop. Uses -// pack_q4_linear_weight, NOT the W_4X8 nc-pair prepack. -ValueRef legacy_prepack_q4gsw_weight( - ComputeGraph& graph, - const ValueRef qmat2_data) { - std::vector qmat2_orig_sizes = graph.sizes_of(qmat2_data); - const int64_t ndim = graph.dim_of(qmat2_data); - - const int64_t qmat2_width = qmat2_orig_sizes.at(ndim - 1); - const int64_t qmat2_height = qmat2_orig_sizes.at(ndim - 2); - - // For 4-bit quantization, source weight has shape [N, K/2]; each byte - // contains 2 nibbles. - const int64_t K = qmat2_width * 2; - const int64_t N = qmat2_height; - - VK_CHECK_COND(K % 8 == 0); - - // 4-bit blocks: 8 rows of N per block, 4 columns of K per block. - const int64_t N_per_block = 8; - const int64_t K_per_block = 4; - - const int64_t num_blocks_K = utils::div_up(K, K_per_block); - const int64_t num_blocks_N = utils::div_up(N, N_per_block); - - // Layout for the coop GEMV path: packed_weights[n8][k4] (no transposition). - const int64_t output_height = num_blocks_N; - const int64_t output_width = num_blocks_K * 4; - - utils::ivec2 orig_sizes = { - utils::safe_downcast(K), utils::safe_downcast(N)}; - - std::vector qmat2_sizes{output_height, output_width}; - - utils::StorageType storage_type = utils::kTexture2D; - uint32_t max_extent = graph.context()->adapter_ptr()->max_texture2d_dim(); - if (output_width > max_extent * 4 || output_height > max_extent) { - storage_type = utils::kBuffer; - } - - std::string kernel_name = "pack_q4_linear_weight"; - add_storage_type_suffix(kernel_name, storage_type); - - // Reuse the prepack cache so repeated test invocations don't re-run the - // prepack shader against the same weight TensorRef. - ValueRef cached = graph.get_cached_prepack(qmat2_data, kernel_name); - if (is_valid(cached)) { - return cached; - } - - ValueRef qmat2 = graph.add_tensor( - qmat2_sizes, vkapi::kInt, storage_type, utils::kWidthPacked); - - // 4-bit prepack: each thread writes two adjacent blocks along K. - const GlobalWorkGrid gwg( - {utils::safe_downcast(utils::div_up(num_blocks_K, int64_t(2))), - utils::safe_downcast(num_blocks_N), - 1u}, - kTiledWorkGrid); - - graph.prepack_nodes().emplace_back(new PrepackNode( - graph, - VK_KERNEL_FROM_STR(kernel_name), - gwg, - graph.create_lwg(gwg), - qmat2_data, - qmat2, - // UBOs - {}, - // Specialization Constants - {}, - // Push Constants - {graph.sizes_pc_of(qmat2), - PushConstantDataInfo(&orig_sizes, sizeof(utils::ivec2))})); - - graph.cache_prepack(qmat2_data, kernel_name, qmat2); - return qmat2; -} - -// Replacement for the deleted et_vk.linear_q4gsw.default registration. Mirrors -// the legacy q4gsw weight-only path: pack_q4_linear_weight prepack + buffer -// scales/bias prepack + a single DynamicDispatchNode whose pick_shader_fn -// picks coop (M==1) or tiled (M>1) at trigger_resize(). -void add_legacy_q4gsw_linear_node( - ComputeGraph& graph, - const ValueRef fp_input, - const ValueRef weight_data, - const ValueRef weight_scales_data, - const ValueRef group_size_ref, - const ValueRef bias_data, - const ValueRef output) { - std::vector input_sizes = graph.sizes_of(fp_input); - const int64_t K = utils::val_at(-1, input_sizes); - // K must be a multiple of 4 so vec4 input loads are aligned. - VK_CHECK_COND(K % 4 == 0); - - const ValueRef packed_weight = - legacy_prepack_q4gsw_weight(graph, weight_data); - const ValueRef packed_weight_scales = prepack_standard( - graph, weight_scales_data, utils::kBuffer, utils::kWidthPacked); - - TmpTensor dummy_bias( - &graph, {}, graph.dtype_of(output), utils::kBuffer, utils::kWidthPacked); - ValueRef packed_bias = dummy_bias.vref; - uint32_t apply_bias = 0; - if (graph.val_is_not_none(bias_data)) { - packed_bias = - prepack_standard(graph, bias_data, utils::kBuffer, utils::kWidthPacked); - apply_bias = 1; - } - - const int32_t group_size_val = graph.extract_scalar(group_size_ref); - const int32_t K4_per_group = utils::div_up(group_size_val, int32_t(4)); - - vkapi::ParamsBindList param_buffers = { - graph.sizes_ubo(output), graph.sizes_ubo(fp_input)}; - - graph.execute_nodes().emplace_back(new DynamicDispatchNode( - graph, - legacy_q4gsw_pick_shader, - legacy_q4gsw_gwg, - legacy_q4gsw_lwg, - // Inputs and Outputs (legacy 5-binding layout) - {{output, vkapi::kWrite}, - {{fp_input, packed_weight, packed_weight_scales, packed_bias}, - vkapi::kRead}}, - // Shader params buffers - param_buffers, - // Push Constants - {}, - // Specialization Constants - {apply_bias, K4_per_group}, - // Resize args. extra_args.at(0) is unused (was the "is_4bit_flag" - // gate in the legacy multi-precision dispatcher); keep - // weight_data at index 1 so resize logic can read sizes_of(weight_data). - {kDummyValueRef, weight_data}, - legacy_q4gsw_resize_linear_node)); -} - -// Forced-shader dispatch for the coop GEMV nc-Buffer variants (selectors -// 13-16). Reuses the production nc-buffer prepack (shared with W_4X8 GEMM / -// TIN GEMM / sg-GEMV via the prepack cache) — same SSBO payload, tests -// single-prepack viability across prefill + decode. -// -// `kind` selects which (NUM_GROUPS, WORKERS_PER_GROUP) reduction decomposition -// to pin: g1w64 -> LWG=(1,1,64) (one WG per n8 tile), g4w16 -> LWG=(1,4,16), -// g8w8 -> LWG=(1,8,8). The bound shader variant and the dispatch geometry are -// kept in sync (both keyed on `kind`) so the shared-memory layout the shader -// bakes in matches the launched workgroup shape. This forces a fixed -// decomposition regardless of N, mirroring what the production picker would -// pick at a given N but at a shape small enough for the reference impl to run. -void add_q4gsw_linear_coop_kc_forced_node( - ComputeGraph& graph, - const ValueRef fp_input, - const ValueRef weight_data, - const ValueRef weight_scales_data, - const ValueRef group_size_ref, - const ValueRef bias_data, - const ValueRef output, - TestKernelKind kind) { - const vkapi::ScalarType in_dtype = graph.dtype_of(fp_input); - - const int64_t group_size_val = graph.extract_scalar(group_size_ref); - - std::vector weight_sizes = graph.sizes_of(weight_data); - const int64_t K = weight_sizes.at(1) * 2; - const uint32_t K_val = static_cast(K); - - const ValueRef packed_weight_kc = - prepack_q4_w_4x8_nc_buffer(graph, weight_data); - const ValueRef packed_scales = - prepack_q4_scales(graph, weight_scales_data, in_dtype); - - TmpTensor dummy_bias( - &graph, {}, graph.dtype_of(output), utils::kBuffer, utils::kWidthPacked); - ValueRef packed_bias = dummy_bias.vref; - uint32_t apply_bias = 0; - if (graph.val_is_not_none(bias_data)) { - packed_bias = - prepack_standard(graph, bias_data, utils::kBuffer, utils::kWidthPacked); - apply_bias = 1; - } - - TmpTensor dummy_transposed_input( - &graph, {}, in_dtype, utils::kBuffer, utils::kWidthPacked); - - using PickShaderFn = vkapi::ShaderInfo (*)( - ComputeGraph*, - const std::vector&, - const std::vector&); - using PickGwgFn = GlobalWorkGrid (*)( - ComputeGraph*, - const vkapi::ShaderInfo&, - const std::vector&, - const std::vector&); - using PickLwgFn = LocalWorkGroup (*)( - ComputeGraph*, - const vkapi::ShaderInfo&, - const GlobalWorkGrid&, - const std::vector&, - const std::vector&); - - PickShaderFn pick_shader = nullptr; - PickGwgFn pick_gwg = nullptr; - PickLwgFn pick_lwg = nullptr; - - // NOLINTNEXTLINE(clang-diagnostic-switch-enum) - switch (kind) { - case TestKernelKind::GEMV_COOP_W_4X8_NC_BUFFER: - case TestKernelKind::GEMV_COOP_W_4X8_NC_BUFFER_G1W64: - pick_shader = - pick_forced_shader_coop_kc; - pick_gwg = pick_q4gsw_coop_gwg<1u, 64u>; - pick_lwg = pick_q4gsw_coop_lwg<1u, 64u>; - break; - case TestKernelKind::GEMV_COOP_W_4X8_NC_BUFFER_G4W16: - pick_shader = pick_forced_shader_coop_kc< - TestKernelKind::GEMV_COOP_W_4X8_NC_BUFFER_G4W16>; - pick_gwg = pick_q4gsw_coop_gwg<4u, 16u>; - pick_lwg = pick_q4gsw_coop_lwg<4u, 16u>; - break; - case TestKernelKind::GEMV_COOP_W_4X8_NC_BUFFER_G8W8: - pick_shader = pick_forced_shader_coop_kc< - TestKernelKind::GEMV_COOP_W_4X8_NC_BUFFER_G8W8>; - pick_gwg = pick_q4gsw_coop_gwg<8u, 8u>; - pick_lwg = pick_q4gsw_coop_lwg<8u, 8u>; - break; - default: - VK_THROW("add_q4gsw_linear_coop_kc_forced_node: non-coop kind"); - } - - graph.execute_nodes().emplace_back(new DynamicDispatchNode( - graph, - pick_shader, - pick_gwg, - pick_lwg, - {{output, vkapi::kWrite}, - {{fp_input, - dummy_transposed_input.vref, - packed_weight_kc, - packed_scales, - packed_bias}, - vkapi::kRead}}, - {graph.sizes_ubo(output), graph.sizes_ubo(fp_input)}, - {}, - {apply_bias, K_val, static_cast(group_size_val)}, - {weight_data, fp_input}, - resize_q4gsw_linear_node)); -} - -// Forced-shader dispatch path. Used only by selectors 1 and 2. -void add_q4gsw_linear_forced_node( - ComputeGraph& graph, - const ValueRef fp_input, - const ValueRef weight_data, - const ValueRef weight_scales_data, - const ValueRef group_size_ref, - const ValueRef bias_data, - const ValueRef output, - TestKernelKind kind) { - const vkapi::ScalarType in_dtype = graph.dtype_of(fp_input); - - const int64_t group_size_val = graph.extract_scalar(group_size_ref); - - std::vector weight_sizes = graph.sizes_of(weight_data); - const int64_t K = weight_sizes.at(1) * 2; - const uint32_t K_val = static_cast(K); - - const ValueRef packed_weight = prepack_q4_w_4x8_nc_buffer(graph, weight_data); - const ValueRef packed_scales = - prepack_q4_scales(graph, weight_scales_data, in_dtype); - - TmpTensor dummy_bias( - &graph, {}, graph.dtype_of(output), utils::kBuffer, utils::kWidthPacked); - ValueRef packed_bias = dummy_bias.vref; - uint32_t apply_bias = 0; - if (graph.val_is_not_none(bias_data)) { - packed_bias = - prepack_standard(graph, bias_data, utils::kBuffer, utils::kWidthPacked); - apply_bias = 1; - } - - // GEMM_TIN_W_4X8 needs a real transposed_input + transpose preprocess - // dispatch. Other forced kinds use a 0-element dummy — the bound shader - // never reads the slot. - const bool need_transpose = (kind == TestKernelKind::GEMM_TIN_W_4X8); - - std::vector in_sizes = graph.sizes_of(fp_input); - const uint32_t M_val = - utils::safe_downcast(utils::val_at(-2, in_sizes)); - const int64_t M4 = (static_cast(M_val) + 3) / 4; - - TmpTensor dummy_transposed_input( - &graph, {}, in_dtype, utils::kBuffer, utils::kWidthPacked); - TmpTensor real_transposed_input( - &graph, - {static_cast(K_val) * M4 * 4}, - in_dtype, - utils::kBuffer, - utils::kWidthPacked); - - ValueRef transposed_input_ref; - if (need_transpose) { - transposed_input_ref = real_transposed_input.vref; - add_transpose_cast_contig_to_vectorized_node( - graph, fp_input, transposed_input_ref); - } else { - transposed_input_ref = dummy_transposed_input.vref; - } - - using PickShaderFn = vkapi::ShaderInfo (*)( - ComputeGraph*, - const std::vector&, - const std::vector&); - using PickGwgFn = GlobalWorkGrid (*)( - ComputeGraph*, - const vkapi::ShaderInfo&, - const std::vector&, - const std::vector&); - using PickLwgFn = LocalWorkGroup (*)( - ComputeGraph*, - const vkapi::ShaderInfo&, - const GlobalWorkGrid&, - const std::vector&, - const std::vector&); - - PickShaderFn pick_shader = nullptr; - PickGwgFn pick_gwg = nullptr; - PickLwgFn pick_lwg = nullptr; - - // NOLINTNEXTLINE(clang-diagnostic-switch-enum) - switch (kind) { - case TestKernelKind::GEMM_W_4X8: - pick_shader = pick_forced_shader; - pick_gwg = pick_q4gsw_linear_gemm_gwg; - pick_lwg = pick_q4gsw_linear_gemm_lwg; - break; - case TestKernelKind::GEMV_W_4X8: - pick_shader = pick_forced_shader; - pick_gwg = pick_q4gsw_legacy_gemv_gwg; - pick_lwg = pick_q4gsw_legacy_gemv_lwg; - break; - case TestKernelKind::GEMM_TIN_W_4X8: - pick_shader = pick_forced_shader; - pick_gwg = pick_q4gsw_linear_tin_gemm_gwg; - pick_lwg = pick_q4gsw_linear_tin_gemm_lwg; - break; - case TestKernelKind::GEMV_W_4X8_NOSG: - pick_shader = pick_forced_shader; - pick_gwg = pick_q4gsw_legacy_gemv_gwg; - pick_lwg = pick_q4gsw_legacy_gemv_lwg; - break; - case TestKernelKind::PROD: - default: - VK_THROW("PROD kind must be dispatched via production entry points"); - } - - graph.execute_nodes().emplace_back(new DynamicDispatchNode( - graph, - pick_shader, - pick_gwg, - pick_lwg, - {{output, vkapi::kWrite}, - {{fp_input, - transposed_input_ref, - packed_weight, - packed_scales, - packed_bias}, - vkapi::kRead}}, - {graph.sizes_ubo(output), graph.sizes_ubo(fp_input)}, - {}, - {apply_bias, K_val, static_cast(group_size_val)}, - {weight_data, fp_input}, - resize_q4gsw_linear_node)); -} - -void add_fpa_q4gsw_linear_node( - ComputeGraph& graph, - const ValueRef fp_input, - const ValueRef weight_data, - const ValueRef weight_scales_data, - const ValueRef group_size_ref, - const ValueRef bias_data, - int32_t impl_selector_int, - bool is_gemv, - const ValueRef output) { - TestKernelKind kind = selector_to_kind(impl_selector_int, is_gemv); - - if (kind == TestKernelKind::PROD) { - // PROD: dispatch through the registered production op so the test exercises - // the same wrapping the partitioner-emitted graph would hit. - std::vector q4gsw_linear_args = { - fp_input, - weight_data, - weight_scales_data, - group_size_ref, - bias_data, - output}; - VK_GET_OP_FN("et_vk.q4gsw_linear.default")(graph, q4gsw_linear_args); - return; - } - - if (kind == TestKernelKind::LEGACY) { - // LEGACY: dispatch the legacy q4gsw linear shaders - // (linear_q4gsw_tiled_* / linear_q4gsw_coop_*) directly via a private - // copy of the dispatcher that was deleted from QuantizedLinear.cpp by the - // W_4X8 commit. Uses pack_q4_linear_weight prepack and picks GEMM vs GEMV - // internally based on input M. - add_legacy_q4gsw_linear_node( - graph, - fp_input, - weight_data, - weight_scales_data, - group_size_ref, - bias_data, - output); - return; - } - - if (kind == TestKernelKind::GEMV_COOP_W_4X8_NC_BUFFER || - kind == TestKernelKind::GEMV_COOP_W_4X8_NC_BUFFER_G1W64 || - kind == TestKernelKind::GEMV_COOP_W_4X8_NC_BUFFER_G4W16 || - kind == TestKernelKind::GEMV_COOP_W_4X8_NC_BUFFER_G8W8) { - // Coop GEMV nc-Buffer variants — `kind` pins the (NUM_GROUPS, - // WORKERS_PER_GROUP) reduction decomposition (g1w64 / g4w16 / g8w8). Weight - // binding is the production nc-buffer SSBO (shared prepack with prefill). - add_q4gsw_linear_coop_kc_forced_node( - graph, - fp_input, - weight_data, - weight_scales_data, - group_size_ref, - bias_data, - output, - kind); - return; - } - - add_q4gsw_linear_forced_node( - graph, - fp_input, - weight_data, - weight_scales_data, - group_size_ref, - bias_data, - output, - kind); -} - -} // namespace - -void test_fpa_q4gsw_linear_gemm( - ComputeGraph& graph, - const std::vector& args) { - int32_t idx = 0; - const ValueRef fp_input = args.at(idx++); - const ValueRef weight_data = args.at(idx++); - const ValueRef weight_scales_data = args.at(idx++); - const ValueRef group_size_ref = args.at(idx++); - const ValueRef bias_data = args.at(idx++); - const ValueRef impl_selector_ref = args.at(idx++); - const ValueRef output = args.at(idx++); - - const int32_t impl_selector_int = - graph.extract_scalar(impl_selector_ref); - - add_fpa_q4gsw_linear_node( - graph, - fp_input, - weight_data, - weight_scales_data, - group_size_ref, - bias_data, - impl_selector_int, - /*is_gemv=*/false, - output); -} - -void test_fpa_q4gsw_linear_gemv( - ComputeGraph& graph, - const std::vector& args) { - int32_t idx = 0; - const ValueRef fp_input = args.at(idx++); - const ValueRef weight_data = args.at(idx++); - const ValueRef weight_scales_data = args.at(idx++); - const ValueRef group_size_ref = args.at(idx++); - const ValueRef bias_data = args.at(idx++); - const ValueRef impl_selector_ref = args.at(idx++); - const ValueRef output = args.at(idx++); - - const int32_t impl_selector_int = - graph.extract_scalar(impl_selector_ref); - - add_fpa_q4gsw_linear_node( - graph, - fp_input, - weight_data, - weight_scales_data, - group_size_ref, - bias_data, - impl_selector_int, - /*is_gemv=*/true, - output); -} - -REGISTER_OPERATORS { - VK_REGISTER_OP( - test_etvk.test_fpa_q4gsw_linear.gemm, test_fpa_q4gsw_linear_gemm); - VK_REGISTER_OP( - test_etvk.test_fpa_q4gsw_linear.gemv, test_fpa_q4gsw_linear_gemv); -} - -} // namespace vkcompute diff --git a/backends/vulkan/test/ops/benchmark/test_ops/impl/TestMatmulLinear.cpp b/backends/vulkan/test/ops/benchmark/test_ops/impl/TestMatmulLinear.cpp deleted file mode 100644 index fc5fb20cab4..00000000000 --- a/backends/vulkan/test/ops/benchmark/test_ops/impl/TestMatmulLinear.cpp +++ /dev/null @@ -1,154 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. - */ - -#include - -#include -#include -#include -#include -#include -#include -#include -#include - -namespace vkcompute { - -// impl_selector values: -// "default" use the registered aten.{mm,bmm,addmm,linear}.default op -// (the same routing production code goes through) -// "coopmat" force the cooperative-matrix path -// "tiled" force the tiled path (linear_vec / matmul_vec) - -void test_mm(ComputeGraph& graph, const std::vector& args) { - const ValueRef mat1 = args.at(0); - const ValueRef mat2 = args.at(1); - const ValueRef impl_selector_str = args.at(2); - const ValueRef out = args.at(3); - - std::string impl_selector = graph.extract_string(impl_selector_str); - - if (impl_selector == "coopmat") { - if (graph.val_is_tref(mat2)) { - auto mat2_sizes = graph.sizes_of(mat2); - int64_t B = mat2_sizes.size() >= 3 ? mat2_sizes.at(0) : 1; - ValueRef packed = prepack_fp_linear_weight( - graph, mat2, /*is_transposed=*/false, B, /*force_buffer=*/true); - add_linear_coopmat_node( - graph, - mat1, - packed, - kDummyValueRef, - false, - out, - utils::safe_downcast(B)); - } else { - add_matmul_coopmat_node(graph, mat1, mat2, out); - } - return; - } - - if (impl_selector == "tiled") { - if (graph.val_is_tref(mat2)) { - auto mat2_sizes = graph.sizes_of(mat2); - int64_t B = mat2_sizes.size() >= 3 ? mat2_sizes.at(0) : 1; - ValueRef packed = - prepack_fp_linear_weight(graph, mat2, /*is_transposed=*/false, B); - add_linear_tiled_node( - graph, - mat1, - packed, - kDummyValueRef, - false, - out, - utils::safe_downcast(B)); - } else { - add_matmul_tiled_node(graph, mat1, mat2, out); - } - return; - } - - std::string op_name = "aten.mm." + impl_selector; - VK_GET_OP_FN(op_name.c_str())(graph, {mat1, mat2, out}); -} - -void test_bmm(ComputeGraph& graph, const std::vector& args) { - const ValueRef mat1 = args.at(0); - const ValueRef mat2 = args.at(1); - const ValueRef impl_selector_str = args.at(2); - const ValueRef out = args.at(3); - - std::string impl_selector = graph.extract_string(impl_selector_str); - std::string op_name = "aten.bmm." + impl_selector; - - VK_GET_OP_FN(op_name.c_str())(graph, {mat1, mat2, out}); -} - -void test_addmm(ComputeGraph& graph, const std::vector& args) { - const ValueRef self = args.at(0); - const ValueRef mat1 = args.at(1); - const ValueRef mat2 = args.at(2); - const ValueRef beta = args.at(3); - const ValueRef alpha = args.at(4); - const ValueRef impl_selector_str = args.at(5); - const ValueRef out = args.at(6); - - std::string impl_selector = graph.extract_string(impl_selector_str); - std::string op_name = "aten.addmm." + impl_selector; - - VK_GET_OP_FN(op_name.c_str())(graph, {self, mat1, mat2, beta, alpha, out}); -} - -void test_linear(ComputeGraph& graph, const std::vector& args) { - const ValueRef input = args.at(0); - const ValueRef weight = args.at(1); - const ValueRef bias = args.at(2); - const ValueRef impl_selector_str = args.at(3); - const ValueRef out = args.at(4); - - std::string impl_selector = graph.extract_string(impl_selector_str); - - if (impl_selector == "coopmat" || impl_selector == "tiled") { - bool has_bias = graph.val_is_not_none(bias); - bool force_buffer = (impl_selector == "coopmat"); - ValueRef packed_weight = prepack_fp_linear_weight( - graph, weight, /*is_transposed=*/true, /*B=*/1, force_buffer); - ValueRef packed_bias = kDummyValueRef; - if (has_bias) { - // passthrough=false: actually prepack into the storage/layout the - // selected shader expects. Coopmat's t_bias binding is a buffer - // scalar array; a passthrough on a non-buffer bias would mismatch. - packed_bias = prepack_standard( - graph, - bias, - graph.storage_type_of(out), - utils::kWidthPacked, - /*passthrough=*/false); - } - if (impl_selector == "coopmat") { - add_linear_coopmat_node( - graph, input, packed_weight, packed_bias, has_bias, out); - } else { - add_linear_tiled_node( - graph, input, packed_weight, packed_bias, has_bias, out); - } - return; - } - - std::string op_name = "aten.linear." + impl_selector; - VK_GET_OP_FN(op_name.c_str())(graph, {input, weight, bias, out}); -} - -REGISTER_OPERATORS { - VK_REGISTER_OP(test_etvk.test_mm.default, test_mm); - VK_REGISTER_OP(test_etvk.test_bmm.default, test_bmm); - VK_REGISTER_OP(test_etvk.test_addmm.default, test_addmm); - VK_REGISTER_OP(test_etvk.test_linear.default, test_linear); -} - -} // namespace vkcompute diff --git a/backends/vulkan/test/ops/benchmark/test_ops/impl/TestQ8taBinary.cpp b/backends/vulkan/test/ops/benchmark/test_ops/impl/TestQ8taBinary.cpp deleted file mode 100644 index 00b042c8180..00000000000 --- a/backends/vulkan/test/ops/benchmark/test_ops/impl/TestQ8taBinary.cpp +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. - */ - -#include - -#include -#include -#include - -namespace vkcompute { - -void q8ta_add_test(ComputeGraph& graph, const std::vector& args) { - int32_t idx = 0; - ValueRef fp_input_a = args.at(idx++); - ValueRef input_b = args.at(idx++); - const ValueRef input_a_scale = args.at(idx++); - const ValueRef input_a_zp = args.at(idx++); - const ValueRef input_b_scale = args.at(idx++); - const ValueRef input_b_zp = args.at(idx++); - const ValueRef output_scale = args.at(idx++); - const ValueRef output_zp = args.at(idx++); - const ValueRef alpha = args.at(idx++); - const ValueRef quant_layout_int = args.at(idx++); - const ValueRef fp_output = args.at(idx++); - - // Extract the layout parameter and cast to GPUMemoryLayout - int32_t layout_value = graph.extract_scalar(quant_layout_int); - utils::GPUMemoryLayout quant_layout = - static_cast(layout_value); - - // Check if input_b is a pre-quantized int8 TensorRef - bool input_b_is_int8 = - graph.val_is_tref(input_b) && graph.dtype_of(input_b) == vkapi::kChar; - - // Create temporary tensors for quantized data with the specified layout - TmpTensor packed_int8_input_a( - &graph, - graph.sizes_of(fp_input_a), - vkapi::kInt8x4, - utils::kBuffer, - quant_layout); - - ValueRef packed_int8_input_b = graph.add_tensor( - graph.sizes_of(input_b), vkapi::kInt8x4, utils::kBuffer, quant_layout); - - TmpTensor packed_int8_output( - &graph, - graph.sizes_of(fp_output), - vkapi::kInt8x4, - utils::kBuffer, - quant_layout); - - // Quantize input A: FP -> int8x4 - add_q8ta_quantize_node( - graph, fp_input_a, input_a_scale, input_a_zp, packed_int8_input_a); - - if (input_b_is_int8) { - // Input B is a pre-quantized int8 TensorRef; prepack directly into packed - // int8x4 format - add_prepack_int8x4_buffer_node(graph, input_b, packed_int8_input_b); - } else { - // Input B is a float tensor; quantize at runtime - add_q8ta_quantize_node( - graph, input_b, input_b_scale, input_b_zp, packed_int8_input_b); - } - - // Binary add: int8x4 -> int8x4 (same layout for all tensors) - add_q8ta_binary_node( - graph, - packed_int8_input_a, - packed_int8_input_b, - input_a_scale, - input_a_zp, - input_b_scale, - input_b_zp, - output_scale, - output_zp, - alpha, - packed_int8_output, - "add"); - - // Dequantize: int8x4 -> FP - add_q8ta_dequantize_node( - graph, packed_int8_output, output_scale, output_zp, fp_output); -} - -REGISTER_OPERATORS { - VK_REGISTER_OP(et_vk.q8ta_add.test, q8ta_add_test); -} - -} // namespace vkcompute diff --git a/backends/vulkan/test/ops/benchmark/test_ops/impl/TestQ8taClone.cpp b/backends/vulkan/test/ops/benchmark/test_ops/impl/TestQ8taClone.cpp deleted file mode 100644 index 9d4c46e79b0..00000000000 --- a/backends/vulkan/test/ops/benchmark/test_ops/impl/TestQ8taClone.cpp +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. - */ - -#include - -#include -#include - -namespace vkcompute { - -void q8ta_clone_test(ComputeGraph& graph, const std::vector& args) { - int32_t idx = 0; - const ValueRef fp_input = args.at(idx++); - const ValueRef scale = args.at(idx++); - const ValueRef zero_point = args.at(idx++); - const ValueRef inp_layout_int = args.at(idx++); - const ValueRef outp_layout_int = args.at(idx++); - const ValueRef fp_output = args.at(idx++); - - // Extract the layout parameters and cast to GPUMemoryLayout - int32_t inp_layout_value = graph.extract_scalar(inp_layout_int); - utils::GPUMemoryLayout inp_layout = - static_cast(inp_layout_value); - - int32_t outp_layout_value = graph.extract_scalar(outp_layout_int); - utils::GPUMemoryLayout outp_layout = - static_cast(outp_layout_value); - - // Create temporary tensor for quantized input with input layout - TmpTensor packed_int8_input( - &graph, - graph.sizes_of(fp_input), - vkapi::kInt8x4, - utils::kBuffer, - inp_layout); - - // Create temporary tensor for quantized output with output layout - TmpTensor packed_int8_output( - &graph, - graph.sizes_of(fp_input), - vkapi::kInt8x4, - utils::kBuffer, - outp_layout); - - // Quantize: FP -> int8x4 with input layout - add_q8ta_quantize_node(graph, fp_input, scale, zero_point, packed_int8_input); - - // Clone: int8x4 (input layout) -> int8x4 (output layout) - add_q8ta_clone_node(graph, packed_int8_input, packed_int8_output); - - // Dequantize: int8x4 with output layout -> FP - add_q8ta_dequantize_node( - graph, packed_int8_output, scale, zero_point, fp_output); -} - -REGISTER_OPERATORS { - VK_REGISTER_OP(test_etvk.q8ta_clone_test.default, q8ta_clone_test); -} - -} // namespace vkcompute diff --git a/backends/vulkan/test/ops/benchmark/test_ops/impl/TestQ8taConv2d.cpp b/backends/vulkan/test/ops/benchmark/test_ops/impl/TestQ8taConv2d.cpp deleted file mode 100644 index 4da454040e7..00000000000 --- a/backends/vulkan/test/ops/benchmark/test_ops/impl/TestQ8taConv2d.cpp +++ /dev/null @@ -1,495 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. - */ - -#include - -#include -#include -#include -#include -#include - -namespace vkcompute { - -namespace { - -void assert_im2col_kernel_selection( - ComputeGraph& graph, - const bool expect_unsigned, - const bool expect_buffer_weights, - const bool expect_fallback_kernel = false) { - const vkapi::Adapter* const adapter = graph.context()->adapter_ptr(); - std::string expected_execute; - std::string expected_prepack; - if (expect_unsigned) { - expected_execute = expect_buffer_weights - ? "q8ta_conv2d_pw_unsigned_buffer_float" - : "q8ta_conv2d_pw_unsigned_float"; - expected_prepack = expect_buffer_weights - ? "pack_q8_linear_weight_unsigned_buffer" - : "pack_q8_linear_weight_unsigned_texture2d"; - } else { - VK_CHECK_COND(!expect_buffer_weights); - if (expect_fallback_kernel) { - expected_execute = "q8ta_conv2d_pw_fallback_float"; - } else { - expected_execute = adapter->supports_int8_dot_product() - ? "q8ta_conv2d_pw_float" - : "q8ta_conv2d_pw_fallback_float"; - } - expected_prepack = "pack_q8_linear_weight_texture2d"; - } - - int32_t execute_matches = 0; - std::string execute_names; - for (const auto& node : graph.execute_nodes()) { - const ExecuteNode* const node_ptr = node.get(); - VK_CHECK_COND(node_ptr != nullptr); - const std::string& node_name = node_ptr->name(); - execute_names += node_name + " "; - if (node_name.find("q8ta_conv2d_pw") == 0) { - VK_CHECK_COND( - node_name == expected_execute, - "Expected ", - expected_execute, - " but selected execute kernel ", - node_name); - ++execute_matches; - } - } - VK_CHECK_COND(execute_matches > 0, "Execute kernels: ", execute_names); - - int32_t prepack_matches = 0; - std::string prepack_names; - for (const auto& node : graph.prepack_nodes()) { - const PrepackNode* const node_ptr = node.get(); - VK_CHECK_COND(node_ptr != nullptr); - const std::string& node_name = node_ptr->name(); - prepack_names += node_name + " "; - if (node_name.find("pack_q8_linear_weight") == 0) { - VK_CHECK_COND( - node_name == expected_prepack, - "Expected ", - expected_prepack, - " but selected prepack kernel ", - node_name); - ++prepack_matches; - } - } - VK_CHECK_COND(prepack_matches > 0, "Prepack kernels: ", prepack_names); -} - -void assert_pw_kernel_selection( - ComputeGraph& graph, - const bool expect_unsigned, - const bool expect_buffer_weights) { - const vkapi::Adapter* const adapter = graph.context()->adapter_ptr(); - std::string expected_execute; - std::string expected_prepack; - if (expect_unsigned) { - expected_execute = "q8ta_conv2d_pw_unsigned_float"; - expected_prepack = expect_buffer_weights - ? "pack_q8_conv2d_weights_unsigned_buffer" - : "pack_q8_conv2d_weights_unsigned_texture2d"; - } else { - VK_CHECK_COND(!expect_buffer_weights); - expected_execute = adapter->supports_int8_dot_product() - ? "q8ta_conv2d_pw_float" - : "q8ta_conv2d_pw_fallback_float"; - expected_prepack = "pack_q8_conv2d_weights_texture2d"; - } - - int32_t execute_matches = 0; - std::string execute_names; - for (const auto& node : graph.execute_nodes()) { - const ExecuteNode* const node_ptr = node.get(); - VK_CHECK_COND(node_ptr != nullptr); - const std::string& node_name = node_ptr->name(); - execute_names += node_name + " "; - if (node_name.find("q8ta_conv2d_pw") == 0) { - VK_CHECK_COND( - node_name == expected_execute, - "Expected ", - expected_execute, - " but selected execute kernel ", - node_name); - ++execute_matches; - } - } - VK_CHECK_COND(execute_matches > 0, "Execute kernels: ", execute_names); - - int32_t prepack_matches = 0; - std::string prepack_names; - for (const auto& node : graph.prepack_nodes()) { - const PrepackNode* const node_ptr = node.get(); - VK_CHECK_COND(node_ptr != nullptr); - const std::string& node_name = node_ptr->name(); - prepack_names += node_name + " "; - if (node_name.find("pack_q8_conv2d_weights") == 0) { - VK_CHECK_COND( - node_name == expected_prepack, - "Expected ", - expected_prepack, - " but selected prepack kernel ", - node_name); - ++prepack_matches; - } - } - VK_CHECK_COND(prepack_matches > 0, "Prepack kernels: ", prepack_names); -} - -} // namespace - -void test_q8ta_conv2d_dw( - ComputeGraph& graph, - const std::vector& args) { - int32_t idx = 0; - const ValueRef fp_input = args.at(idx++); - const ValueRef input_scale = args.at(idx++); - const ValueRef input_zp = args.at(idx++); - const ValueRef weight_data = args.at(idx++); - const ValueRef weight_sums_data = args.at(idx++); - const ValueRef weight_scales_data = args.at(idx++); - const ValueRef output_scale = args.at(idx++); - const ValueRef output_zp = args.at(idx++); - const ValueRef bias_data = args.at(idx++); - const ValueRef kernel_size = args.at(idx++); - const ValueRef stride = args.at(idx++); - const ValueRef padding = args.at(idx++); - const ValueRef dilation = args.at(idx++); - const ValueRef groups = args.at(idx++); - const ValueRef activation = args.at(idx++); - const ValueRef layout_int = args.at(idx++); - const ValueRef impl_selector_str = args.at(idx++); - const ValueRef fp_output = args.at(idx++); - - // Extract the layout parameter and cast to GPUMemoryLayout - int32_t layout_value = graph.extract_scalar(layout_int); - utils::GPUMemoryLayout layout = - static_cast(layout_value); - - // Extract the impl_selector string - std::string impl_selector = graph.extract_string(impl_selector_str); - - // Create temporary packed int8 tensors for input and output - TmpTensor packed_int8_input( - &graph, graph.sizes_of(fp_input), vkapi::kInt8x4, utils::kBuffer, layout); - - TmpTensor packed_int8_output( - &graph, - graph.sizes_of(fp_output), - vkapi::kInt8x4, - utils::kBuffer, - layout); - - // Quantize floating point input to packed int8 - add_q8ta_quantize_node( - graph, fp_input, input_scale, input_zp, packed_int8_input); - - if (impl_selector == "legacy_4w4c") { - // Legacy path does not support activation - std::vector conv_args = { - packed_int8_input, - input_scale, - input_zp, - weight_data, - weight_sums_data, - weight_scales_data, - output_scale, - output_zp, - bias_data, - kernel_size, - stride, - padding, - dilation, - groups, - packed_int8_output}; - VK_GET_OP_FN("et_vk.conv2d_q8ta_q8csw_q8to.default")(graph, conv_args); - } else { - std::vector conv_args = { - packed_int8_input, - input_scale, - input_zp, - weight_data, - weight_sums_data, - weight_scales_data, - output_scale, - output_zp, - bias_data, - kernel_size, - stride, - padding, - dilation, - groups, - activation, - packed_int8_output}; - VK_GET_OP_FN("et_vk.q8ta_conv2d_dw.default")(graph, conv_args); - } - - // Dequantize packed int8 output to floating point - add_q8ta_dequantize_node( - graph, packed_int8_output, output_scale, output_zp, fp_output); -} - -void test_q8ta_conv2d(ComputeGraph& graph, const std::vector& args) { - int32_t idx = 0; - const ValueRef fp_input = args.at(idx++); - const ValueRef input_scale = args.at(idx++); - const ValueRef input_zp = args.at(idx++); - const ValueRef weight_data = args.at(idx++); - const ValueRef weight_sums_data = args.at(idx++); - const ValueRef weight_scales_data = args.at(idx++); - const ValueRef output_scale = args.at(idx++); - const ValueRef output_zp = args.at(idx++); - const ValueRef bias_data = args.at(idx++); - const ValueRef kernel_size = args.at(idx++); - const ValueRef stride = args.at(idx++); - const ValueRef padding = args.at(idx++); - const ValueRef dilation = args.at(idx++); - const ValueRef groups = args.at(idx++); - const ValueRef activation = args.at(idx++); - const ValueRef input_layout_int = args.at(idx++); - const ValueRef output_layout_int = args.at(idx++); - const ValueRef impl_selector_str = args.at(idx++); - const ValueRef fp_output = args.at(idx++); - - // Extract the layout parameter and cast to GPUMemoryLayout - const auto input_layout = static_cast( - graph.extract_scalar(input_layout_int)); - const auto output_layout = static_cast( - graph.extract_scalar(output_layout_int)); - - // Extract the impl_selector string - std::string impl_selector = graph.extract_string(impl_selector_str); - - // Create temporary packed int8 tensors for input and output - TmpTensor packed_int8_input( - &graph, - graph.sizes_of(fp_input), - vkapi::kInt8x4, - utils::kBuffer, - input_layout); - - TmpTensor packed_int8_output( - &graph, - graph.sizes_of(fp_output), - vkapi::kInt8x4, - utils::kBuffer, - output_layout); - - // Quantize floating point input to packed int8 - add_q8ta_quantize_node( - graph, fp_input, input_scale, input_zp, packed_int8_input); - - if (impl_selector == "legacy_4w4c") { - // Legacy path does not support activation - std::vector conv_args = { - packed_int8_input, - input_scale, - input_zp, - weight_data, - weight_sums_data, - weight_scales_data, - output_scale, - output_zp, - bias_data, - kernel_size, - stride, - padding, - dilation, - groups, - packed_int8_output}; - VK_GET_OP_FN("et_vk.conv2d_q8ta_q8csw_q8to.default")(graph, conv_args); - } else { - std::vector conv_args = { - packed_int8_input, - input_scale, - input_zp, - weight_data, - weight_sums_data, - weight_scales_data, - output_scale, - output_zp, - bias_data, - kernel_size, - stride, - padding, - dilation, - groups, - activation, - packed_int8_output}; - if (impl_selector == "im2col_fallback") { - // Simulate a device without dot-product support so the fallback - // kernel is selected on any hardware. - vkapi::ScopedAdapterCapabilityOverride no_dot_support( - graph.context()->adapter_ptr(), - vkapi::AdapterCapabilityOverrides::without_dot_product_support()); - q8ta_conv2d_im2col_impl(graph, /*use_unsigned_dot=*/false, conv_args); - assert_im2col_kernel_selection( - graph, - /*expect_unsigned=*/false, - /*expect_buffer_weights=*/false, - /*expect_fallback_kernel=*/true); - } else if ( - impl_selector == "im2col" || impl_selector == "im2col_unsigned" || - impl_selector == "im2col_auto") { - const vkapi::Adapter* const adapter = graph.context()->adapter_ptr(); - bool expect_unsigned = impl_selector == "im2col_unsigned"; - if (impl_selector == "im2col_auto") { - VK_GET_OP_FN("et_vk.q8ta_conv2d_im2col.default")(graph, conv_args); - expect_unsigned = can_use_unsigned_pw_dot( - *adapter, graph.size_at(-1, weight_data)); - } else { - q8ta_conv2d_im2col_impl(graph, expect_unsigned, conv_args); - } - const int64_t packed_height = - utils::div_up_4(graph.size_at(-1, weight_data)); - const int64_t packed_width = - utils::div_up_4(graph.size_at(-2, weight_data)) * 4; - const int64_t max_texture_extent = adapter->max_texture2d_dim(); - const bool expect_buffer_weights = - packed_width > max_texture_extent * 4 || - packed_height > max_texture_extent; - assert_im2col_kernel_selection( - graph, expect_unsigned, expect_buffer_weights); - } else if (impl_selector == "general") { - VK_GET_OP_FN("et_vk.q8ta_conv2d_general.default")(graph, conv_args); - } else { - VK_GET_OP_FN("et_vk.q8ta_conv2d.default")(graph, conv_args); - } - } - - // Dequantize packed int8 output to floating point - add_q8ta_dequantize_node( - graph, packed_int8_output, output_scale, output_zp, fp_output); -} - -void test_q8ta_conv2d_pw( - ComputeGraph& graph, - const std::vector& args) { - int32_t idx = 0; - const ValueRef fp_input = args.at(idx++); - const ValueRef input_scale = args.at(idx++); - const ValueRef input_zp = args.at(idx++); - const ValueRef weight_data = args.at(idx++); - const ValueRef weight_sums_data = args.at(idx++); - const ValueRef weight_scales_data = args.at(idx++); - const ValueRef output_scale = args.at(idx++); - const ValueRef output_zp = args.at(idx++); - const ValueRef bias_data = args.at(idx++); - const ValueRef kernel_size = args.at(idx++); - const ValueRef stride = args.at(idx++); - const ValueRef padding = args.at(idx++); - const ValueRef dilation = args.at(idx++); - const ValueRef groups = args.at(idx++); - const ValueRef activation = args.at(idx++); - const ValueRef layout_int = args.at(idx++); - const ValueRef impl_selector_str = args.at(idx++); - const ValueRef fp_output = args.at(idx++); - - // Extract the layout parameter and cast to GPUMemoryLayout - int32_t layout_value = graph.extract_scalar(layout_int); - utils::GPUMemoryLayout layout = - static_cast(layout_value); - - // Extract the impl_selector string - std::string impl_selector = graph.extract_string(impl_selector_str); - - // Create temporary packed int8 tensors for input and output - TmpTensor packed_int8_input( - &graph, - graph.sizes_of(fp_input), - vkapi::kInt8x4, - utils::kBuffer, - utils::kPackedInt8_4W4C); - - TmpTensor packed_int8_output( - &graph, - graph.sizes_of(fp_output), - vkapi::kInt8x4, - utils::kBuffer, - layout); - - // Quantize floating point input to packed int8 - add_q8ta_quantize_node( - graph, fp_input, input_scale, input_zp, packed_int8_input); - - if (impl_selector == "legacy_4w4c") { - // Legacy path does not support activation - std::vector conv_args = { - packed_int8_input, - input_scale, - input_zp, - weight_data, - weight_sums_data, - weight_scales_data, - output_scale, - output_zp, - bias_data, - kernel_size, - stride, - padding, - dilation, - groups, - packed_int8_output}; - VK_GET_OP_FN("et_vk.conv2d_q8ta_q8csw_q8to.default")(graph, conv_args); - } else { - std::vector conv_args = { - packed_int8_input, - input_scale, - input_zp, - weight_data, - weight_sums_data, - weight_scales_data, - output_scale, - output_zp, - bias_data, - kernel_size, - stride, - padding, - dilation, - groups, - activation, - packed_int8_output}; - if (impl_selector == "pw_signed" || impl_selector == "pw_unsigned" || - impl_selector == "pw_auto") { - const vkapi::Adapter* const adapter = graph.context()->adapter_ptr(); - bool expect_unsigned = impl_selector == "pw_unsigned"; - if (impl_selector == "pw_auto") { - VK_GET_OP_FN("et_vk.q8ta_conv2d_pw.default")(graph, conv_args); - expect_unsigned = can_use_unsigned_pw_dot( - *adapter, graph.size_at(-1, weight_data)); - } else { - q8ta_conv2d_pw_impl(graph, expect_unsigned, conv_args); - } - const int64_t packed_height = - utils::div_up_4(graph.size_at(-1, weight_data)); - const int64_t packed_width = - utils::div_up_4(graph.size_at(-2, weight_data)) * 4; - const int64_t max_texture_extent = adapter->max_texture2d_dim(); - const bool expect_buffer_weights = - packed_width > max_texture_extent * 4 || - packed_height > max_texture_extent; - assert_pw_kernel_selection(graph, expect_unsigned, expect_buffer_weights); - } else { - VK_GET_OP_FN("et_vk.q8ta_conv2d_pw.default")(graph, conv_args); - } - } - - // Dequantize packed int8 output to floating point - add_q8ta_dequantize_node( - graph, packed_int8_output, output_scale, output_zp, fp_output); -} - -REGISTER_OPERATORS { - VK_REGISTER_OP(test_etvk.test_q8ta_conv2d_dw.default, test_q8ta_conv2d_dw); - VK_REGISTER_OP(test_etvk.test_q8ta_conv2d.default, test_q8ta_conv2d); - VK_REGISTER_OP(test_etvk.test_q8ta_conv2d_pw.default, test_q8ta_conv2d_pw); -} - -} // namespace vkcompute diff --git a/backends/vulkan/test/ops/benchmark/test_ops/impl/TestQ8taConv2dTransposed.cpp b/backends/vulkan/test/ops/benchmark/test_ops/impl/TestQ8taConv2dTransposed.cpp deleted file mode 100644 index 894ce71fed9..00000000000 --- a/backends/vulkan/test/ops/benchmark/test_ops/impl/TestQ8taConv2dTransposed.cpp +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. - */ - -#include - -#include -#include -#include - -namespace vkcompute { - -void test_q8ta_conv2d_transposed( - ComputeGraph& graph, - const std::vector& args) { - int32_t idx = 0; - const ValueRef fp_input = args.at(idx++); - const ValueRef input_scale = args.at(idx++); - const ValueRef input_zp = args.at(idx++); - const ValueRef weight_data = args.at(idx++); - const ValueRef weight_sums_data = args.at(idx++); - const ValueRef weight_scales_data = args.at(idx++); - const ValueRef output_scale = args.at(idx++); - const ValueRef output_zp = args.at(idx++); - const ValueRef bias_data = args.at(idx++); - const ValueRef kernel_size = args.at(idx++); - const ValueRef stride = args.at(idx++); - const ValueRef padding = args.at(idx++); - const ValueRef output_padding = args.at(idx++); - const ValueRef dilation = args.at(idx++); - const ValueRef groups = args.at(idx++); - const ValueRef activation = args.at(idx++); - const ValueRef layout_int = args.at(idx++); - const ValueRef fp_output = args.at(idx++); - - int32_t layout_value = graph.extract_scalar(layout_int); - utils::GPUMemoryLayout layout = - static_cast(layout_value); - - TmpTensor packed_int8_input( - &graph, graph.sizes_of(fp_input), vkapi::kInt8x4, utils::kBuffer, layout); - - TmpTensor packed_int8_output( - &graph, - graph.sizes_of(fp_output), - vkapi::kInt8x4, - utils::kBuffer, - layout); - - add_q8ta_quantize_node( - graph, fp_input, input_scale, input_zp, packed_int8_input); - - std::vector conv_args = { - packed_int8_input, - input_scale, - input_zp, - weight_data, - weight_sums_data, - weight_scales_data, - output_scale, - output_zp, - bias_data, - kernel_size, - stride, - padding, - output_padding, - dilation, - groups, - activation, - packed_int8_output}; - VK_GET_OP_FN("et_vk.q8ta_conv2d_transposed.default")(graph, conv_args); - - add_q8ta_dequantize_node( - graph, packed_int8_output, output_scale, output_zp, fp_output); -} - -REGISTER_OPERATORS { - VK_REGISTER_OP( - test_etvk.test_q8ta_conv2d_transposed.default, - test_q8ta_conv2d_transposed); -} - -} // namespace vkcompute diff --git a/backends/vulkan/test/ops/benchmark/test_ops/impl/TestQ8taLinear.cpp b/backends/vulkan/test/ops/benchmark/test_ops/impl/TestQ8taLinear.cpp deleted file mode 100644 index 684a7b94e66..00000000000 --- a/backends/vulkan/test/ops/benchmark/test_ops/impl/TestQ8taLinear.cpp +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. - */ - -#include - -#include -#include - -namespace vkcompute { - -void test_q8ta_linear(ComputeGraph& graph, const std::vector& args) { - int32_t idx = 0; - const ValueRef fp_input = args.at(idx++); - const ValueRef input_scale = args.at(idx++); - const ValueRef input_zp = args.at(idx++); - const ValueRef weight_data = args.at(idx++); - const ValueRef weight_sums_data = args.at(idx++); - const ValueRef weight_scales_data = args.at(idx++); - const ValueRef output_scale = args.at(idx++); - const ValueRef output_zp = args.at(idx++); - const ValueRef bias_data = args.at(idx++); - const ValueRef activation = args.at(idx++); - const ValueRef impl_selector_str = args.at(idx++); - const ValueRef fp_output = args.at(idx++); - - std::string impl_selector = graph.extract_string(impl_selector_str); - - utils::GPUMemoryLayout layout = - impl_selector == "gemv" ? utils::kPackedInt8_4W : utils::kPackedInt8_4H4W; - - TmpTensor packed_int8_input( - &graph, graph.sizes_of(fp_input), vkapi::kInt8x4, utils::kBuffer, layout); - - TmpTensor packed_int8_output( - &graph, - graph.sizes_of(fp_output), - vkapi::kInt8x4, - utils::kBuffer, - layout); - - add_q8ta_quantize_node( - graph, fp_input, input_scale, input_zp, packed_int8_input); - - std::vector linear_args = { - packed_int8_input, - input_scale, - input_zp, - weight_data, - weight_sums_data, - weight_scales_data, - output_scale, - output_zp, - bias_data, - activation, - packed_int8_output}; - - std::string op_name = impl_selector == "gemv" - ? "et_vk.q8ta_linear_gemv.default" - : "et_vk.q8ta_linear.default"; - VK_GET_OP_FN(op_name)(graph, linear_args); - - add_q8ta_dequantize_node( - graph, packed_int8_output, output_scale, output_zp, fp_output); -} - -REGISTER_OPERATORS { - VK_REGISTER_OP(test_etvk.test_q8ta_linear.default, test_q8ta_linear); -} - -} // namespace vkcompute diff --git a/backends/vulkan/test/ops/benchmark/test_ops/impl/TestQ8taPixelShuffle.cpp b/backends/vulkan/test/ops/benchmark/test_ops/impl/TestQ8taPixelShuffle.cpp deleted file mode 100644 index c33c9696a33..00000000000 --- a/backends/vulkan/test/ops/benchmark/test_ops/impl/TestQ8taPixelShuffle.cpp +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. - */ - -#include - -#include -#include - -namespace vkcompute { - -namespace { - -// Map a layout name string from the test driver to the corresponding -// channels-packed int8x4 GPUMemoryLayout enum. -// -// Note: PACKED_INT8_CONV2D is a Python/serialization-level alias that the -// runtime resolves to kPackedInt8_4C (im2col convolution inputs) or -// kPackedInt8_4C1W. At the C++ runtime layer there is no distinct -// kPackedInt8_CONV2D enum, so only the resolved layouts are exposed here. -utils::GPUMemoryLayout layout_from_string(const std::string& s) { - if (s == "4W4C") { - return utils::kPackedInt8_4W4C; - } else if (s == "4C") { - return utils::kPackedInt8_4C; - } else if (s == "4C1W") { - return utils::kPackedInt8_4C1W; - } - VK_THROW("Unknown q8ta layout name: " + s); -} - -} // namespace - -// -// Test op: takes a float input and float (re)quantization params, performs -// quantize -> fused pixel_shuffle -> dequantize, returns float output. -// -// The test op signature is: -// test_q8ta_pixel_shuffle( -// Tensor fp_input, -// float input_scale, -// int input_zp, -// float output_scale, -// int output_zp, -// int upscale_factor, -// str in_layout, // "4W4C" | "4C" | "4C1W" -// str out_layout, // "4W4C" | "4C" | "4C1W" -// ) -> Tensor (float, output shape = pixel_shuffle(in, r)) -// - -void test_q8ta_pixel_shuffle( - ComputeGraph& graph, - const std::vector& args) { - int32_t idx = 0; - const ValueRef fp_input = args.at(idx++); - const ValueRef input_scale = args.at(idx++); - const ValueRef input_zp = args.at(idx++); - const ValueRef output_scale = args.at(idx++); - const ValueRef output_zp = args.at(idx++); - const ValueRef upscale_factor = args.at(idx++); - const ValueRef in_layout_ref = args.at(idx++); - const ValueRef out_layout_ref = args.at(idx++); - const ValueRef fp_output = args.at(idx++); - - const int32_t r = graph.extract_scalar(upscale_factor); - - const std::string in_layout_str = graph.extract_string(in_layout_ref); - const std::string out_layout_str = graph.extract_string(out_layout_ref); - const utils::GPUMemoryLayout in_layout = layout_from_string(in_layout_str); - const utils::GPUMemoryLayout out_layout = layout_from_string(out_layout_str); - - const std::vector in_sizes = graph.sizes_of(fp_input); - VK_CHECK_COND(in_sizes.size() == 4); - const int64_t N = in_sizes[0]; - const int64_t C = in_sizes[1]; - const int64_t H = in_sizes[2]; - const int64_t W = in_sizes[3]; - std::vector out_sizes = {N, C / (r * r), H * r, W * r}; - - // Quantize fp_input to int8x4 with the channels-packed input layout. - TmpTensor q_in(&graph, in_sizes, vkapi::kInt8x4, utils::kBuffer, in_layout); - add_q8ta_quantize_node(graph, fp_input, input_scale, input_zp, q_in); - - // int8x4 output tensor with the channels-packed output layout. - TmpTensor q_out( - &graph, out_sizes, vkapi::kInt8x4, utils::kBuffer, out_layout); - - // Fused fast path. The fused kernel takes inv_scale, so compute it from - // output_scale here. - float output_scale_val = graph.extract_scalar(output_scale); - float output_inv_scale_val = 1.0f / output_scale_val; - ValueRef inv_scale_ref = graph.add_scalar(output_inv_scale_val); - add_q8ta_pixel_shuffle_node( - graph, - q_in, - input_scale, - input_zp, - inv_scale_ref, - output_zp, - upscale_factor, - q_out); - - // Dequantize back to fp for correctness comparison. - add_q8ta_dequantize_node(graph, q_out, output_scale, output_zp, fp_output); -} - -REGISTER_OPERATORS { - VK_REGISTER_OP( - test_etvk.test_q8ta_pixel_shuffle.default, test_q8ta_pixel_shuffle); -} - -} // namespace vkcompute diff --git a/backends/vulkan/test/ops/benchmark/test_ops/impl/TestQ8taQuantizeDequantize.cpp b/backends/vulkan/test/ops/benchmark/test_ops/impl/TestQ8taQuantizeDequantize.cpp deleted file mode 100644 index 24f19b4d309..00000000000 --- a/backends/vulkan/test/ops/benchmark/test_ops/impl/TestQ8taQuantizeDequantize.cpp +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. - */ - -#include - -#include -#include - -namespace vkcompute { - -void q_dq_8bit_per_tensor( - ComputeGraph& graph, - const std::vector& args) { - int32_t idx = 0; - const ValueRef fp_input = args.at(idx++); - const ValueRef scale = args.at(idx++); - const ValueRef zero_point = args.at(idx++); - const ValueRef layout_int = args.at(idx++); - const ValueRef impl_selector_str = args.at(idx++); - const ValueRef fp_output = args.at(idx++); - - // Extract the layout parameter and cast to GPUMemoryLayout - int32_t layout_value = graph.extract_scalar(layout_int); - utils::GPUMemoryLayout layout = - static_cast(layout_value); - - // Extract the impl_selector string - std::string impl_selector = graph.extract_string(impl_selector_str); - - // Use legacy 4W4C implementation if requested and layout matches - if (impl_selector == "legacy_4w4c" && layout == utils::kPackedInt8_4W4C) { - TmpTensor packed_int8_input( - &graph, - graph.sizes_of(fp_input), - vkapi::kInt8x4, - utils::kBuffer, - utils::kPackedInt8_4W4C); - - add_quantize_and_pack_4w4c_node( - graph, fp_input, scale, zero_point, packed_int8_input); - - add_unpack_4w4c_and_dequantize_node( - graph, packed_int8_input, scale, zero_point, fp_output); - } else { - // Create temporary tensor with the specified layout - TmpTensor packed_int8_input( - &graph, - graph.sizes_of(fp_input), - vkapi::kInt8x4, - utils::kBuffer, - layout); - - // Use unified block-based dispatch - add_q8ta_quantize_node( - graph, fp_input, scale, zero_point, packed_int8_input); - - add_q8ta_dequantize_node( - graph, packed_int8_input, scale, zero_point, fp_output); - } -} - -REGISTER_OPERATORS { - VK_REGISTER_OP(test_etvk.q_dq_8bit_per_tensor.default, q_dq_8bit_per_tensor); -} - -} // namespace vkcompute diff --git a/backends/vulkan/test/ops/benchmark/test_ops/impl/TestQ8taUnary.cpp b/backends/vulkan/test/ops/benchmark/test_ops/impl/TestQ8taUnary.cpp deleted file mode 100644 index 6212216686f..00000000000 --- a/backends/vulkan/test/ops/benchmark/test_ops/impl/TestQ8taUnary.cpp +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. - */ - -#include - -#include -#include - -namespace vkcompute { - -void q8ta_unary_test(ComputeGraph& graph, const std::vector& args) { - int32_t idx = 0; - const ValueRef fp_input = args.at(idx++); - const ValueRef input_scale = args.at(idx++); - const ValueRef input_zp = args.at(idx++); - const ValueRef output_scale = args.at(idx++); - const ValueRef output_zp = args.at(idx++); - const ValueRef quant_layout_int = args.at(idx++); - const ValueRef fp_output = args.at(idx++); - - int32_t layout_value = graph.extract_scalar(quant_layout_int); - utils::GPUMemoryLayout quant_layout = - static_cast(layout_value); - - // Create temporary tensor for quantized input - TmpTensor packed_int8_input( - &graph, - graph.sizes_of(fp_input), - vkapi::kInt8x4, - utils::kBuffer, - quant_layout); - - // Create temporary tensor for quantized output - TmpTensor packed_int8_output( - &graph, - graph.sizes_of(fp_input), - vkapi::kInt8x4, - utils::kBuffer, - quant_layout); - - // Quantize: FP -> int8x4 - add_q8ta_quantize_node( - graph, fp_input, input_scale, input_zp, packed_int8_input); - - // Unary op: int8x4 -> int8x4 - add_q8ta_unary_node( - graph, - packed_int8_input, - input_scale, - input_zp, - output_scale, - output_zp, - packed_int8_output, - "relu"); - - // Dequantize: int8x4 -> FP - add_q8ta_dequantize_node( - graph, packed_int8_output, output_scale, output_zp, fp_output); -} - -REGISTER_OPERATORS { - VK_REGISTER_OP(test_etvk.q8ta_unary_test.default, q8ta_unary_test); -} - -} // namespace vkcompute diff --git a/backends/vulkan/test/ops/benchmark/test_ops/impl/TestSDPA.cpp b/backends/vulkan/test/ops/benchmark/test_ops/impl/TestSDPA.cpp deleted file mode 100644 index 9ab1630331b..00000000000 --- a/backends/vulkan/test/ops/benchmark/test_ops/impl/TestSDPA.cpp +++ /dev/null @@ -1,226 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. - */ - -#include - -#include -#include - -#include - -namespace vkcompute { - -void sdpa_impl(ComputeGraph& graph, const std::vector& args); -void sdpa_with_kv_cache_impl( - ComputeGraph& graph, - const std::vector& args); - -// Bare-minimum mirror of the LLM decode/prefill path in sdpa_impl() (see -// SDPA.cpp), stripped of the input-validation VK_CHECK_CONDs. Building the -// three SDPA nodes directly lets the test forward a shader_override knob to the -// AV node, which the production op boundary (llama.custom_sdpa) can't carry. -static void test_sdpa_impl( - ComputeGraph& graph, - const ValueRef q_projected, - const ValueRef k_cache, - const ValueRef v_cache, - const ValueRef input_pos_symint, - const ValueRef scale, - const ValueRef out, - const ValueRef shader_override) { - const int64_t num_q_heads = graph.size_at(-2, q_projected); - int64_t max_seq_len = graph.size_at(-3, q_projected); - const int64_t max_context_len = graph.size_at(-3, k_cache); - - const utils::StorageType attn_weights_storage = - graph.storage_type_of(q_projected); - - // S and context dims are padded to a multiple of 4 to match sdpa_impl() (see - // the allocation comment there): the buffer SDPA shaders index each head at - // an align_up_4(S)/align_up_4(context) stride, so the allocation must reserve - // the padded extent or later heads land out of bounds on the buffer path. - const int64_t padded_context_len = utils::align_up_4(max_context_len); - - // Clamp attn-weight seq_len so the PADDED buffer stays within the numel limit - // (mirrors sdpa_impl; a no-op for the texture path the harness uses). The - // check must use the padded sizes actually allocated, not the raw ones. - if (attn_weights_storage == utils::kBuffer) { - const int64_t max_buffer_numel = graph.max_buffer_numel(); - if (num_q_heads * utils::align_up_4(max_seq_len) * padded_context_len >= - max_buffer_numel) { - max_seq_len = max_buffer_numel / (num_q_heads * padded_context_len); - if (max_seq_len % 4 != 0) { - max_seq_len = (max_seq_len / 4) * 4; - } else { - max_seq_len -= 4; - } - } - } - - const std::vector attn_weight_full_sizes = { - 1, num_q_heads, utils::align_up_4(max_seq_len), padded_context_len}; - - TmpTensor attn_weights( - &graph, - attn_weight_full_sizes, - graph.dtype_of(q_projected), - attn_weights_storage, - utils::kWidthPacked); - - TmpTensor attn_weights_softmax( - &graph, - attn_weight_full_sizes, - graph.dtype_of(q_projected), - attn_weights_storage, - utils::kWidthPacked); - - const int32_t head_dim_size = graph.size_at(-1, q_projected); - const float scale_val = graph.val_is_none(scale) - ? 1.0f / std::sqrt(static_cast(head_dim_size)) - : graph.extract_scalar(scale); - - add_sdpa_compute_attn_weights_node( - graph, - q_projected, - k_cache, - input_pos_symint, - /*attn_mask=*/kDummyValueRef, - scale_val, - attn_weights, - SDPAMode::LLM); - - add_sdpa_attn_weights_softmax_node( - graph, - attn_weights, - q_projected, - k_cache, - input_pos_symint, - attn_weights_softmax, - SDPAMode::LLM); - - add_sdpa_compute_out_node( - graph, - attn_weights_softmax, - v_cache, - q_projected, - /*k=*/kDummyValueRef, - input_pos_symint, - out, - SDPAMode::LLM, - shader_override); -} - -// Test wrapper for the LLM KV-cache SDPA op (llama.custom_sdpa.default). -// -// The production op reads the dynamic context length from an `input_pos` -// symint. The KV-cache's dim -3 is its capacity, so input_pos + S must not -// exceed that dimension. -// -// Decode vs prefill is selected automatically inside the nodes via -// is_single_token() (S == 1 -> coop/GEMV shaders, S > 1 -> tiled shaders). For -// single-token decode the AV shader is picked by impl_selector: -// "default" -> auto-select (GQA-reuse coop shader when it applies) -// "gqa" -> force the GQA-reuse coop shader (vendor picks base vs tile2) -// "gqa_tile2" -> force the head_dim output-tiled GQA variant (any device) -// "gqa_base" -> force the base (non-tiled) GQA variant (any device) -// "non_gqa" -> force the per-query-head coop shader -// impl_selector has no effect on prefill (tiled). -// -// Args: q, k_cache, v_cache, k_projected, v_projected, input_pos, scale, -// impl_selector, out -void test_sdpa(ComputeGraph& graph, const std::vector& args) { - int arg_idx = 0; - const ValueRef q = args.at(arg_idx++); - const ValueRef k_cache = args.at(arg_idx++); - const ValueRef v_cache = args.at(arg_idx++); - const ValueRef k_projected = args.at(arg_idx++); - const ValueRef v_projected = args.at(arg_idx++); - const ValueRef input_pos_ref = args.at(arg_idx++); - const ValueRef scale = args.at(arg_idx++); - const ValueRef impl_selector_str = args.at(arg_idx++); - const ValueRef out = args.at(arg_idx++); - - const std::string impl_selector = graph.extract_string(impl_selector_str); - VK_CHECK_COND( - impl_selector == "default" || impl_selector == "gqa" || - impl_selector == "gqa_tile2" || impl_selector == "gqa_base" || - impl_selector == "non_gqa" || impl_selector == "production" || - impl_selector == "fused", - "test_sdpa: impl_selector must be one of {default, gqa, gqa_tile2, " - "gqa_base, non_gqa, production, fused}"); - - const int64_t seq_len = graph.size_at(-3, q); - const int64_t cache_capacity = graph.size_at(-3, k_cache); - const int32_t input_pos_val = graph.extract_scalar(input_pos_ref); - VK_CHECK_COND(input_pos_val >= 0); - VK_CHECK_COND(input_pos_val + seq_len <= cache_capacity); - - const ValueRef input_pos_symint = graph.add_symint(input_pos_val); - - if (impl_selector == "fused") { - sdpa_with_kv_cache_impl( - graph, - {q, - k_projected, - v_projected, - k_cache, - v_cache, - input_pos_symint, - graph.add_scalar(seq_len), - graph.add_none(), - graph.add_scalar(0.0), - graph.add_scalar(true), - scale, - out}); - return; - } - - if (impl_selector == "production") { - sdpa_impl( - graph, - {q, - k_cache, - v_cache, - input_pos_symint, - graph.add_none(), - graph.add_scalar(0.0), - graph.add_scalar(true), - scale, - out}); - return; - } - - // shader_override (see SDPA.h): kDummyValueRef auto; otherwise a - // kShaderOverride* scalar forcing the AV shader family / variant. - ValueRef shader_override = kDummyValueRef; - if (impl_selector == "gqa") { - shader_override = graph.add_scalar(kShaderOverrideForceGqa); - } else if (impl_selector == "gqa_tile2") { - shader_override = graph.add_scalar(kShaderOverrideForceTile2); - } else if (impl_selector == "gqa_base") { - shader_override = graph.add_scalar(kShaderOverrideForceBase); - } else if (impl_selector == "non_gqa") { - shader_override = graph.add_scalar(kShaderOverrideForceNonGqa); - } - - test_sdpa_impl( - graph, - q, - k_cache, - v_cache, - input_pos_symint, - scale, - out, - shader_override); -} - -REGISTER_OPERATORS { - VK_REGISTER_OP(test_etvk.test_sdpa.default, test_sdpa); -} - -} // namespace vkcompute From 79feb102877c5ebcebaedf988650da5abb670031 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Tue, 3 Nov 2026 15:46:53 -0800 Subject: [PATCH 038/128] exir and MLX: fix folded-output clones, shared constants, boolean masks, int64 scatter_add, lowering time and memory, and #24404 follow-ups (#24481) ### Problem Some LeRobot policies fail with Apple's MLX backend: - **smolvla, smolvla_libero and pi0** stop in the MLX partitioner with `SpecViolationError: ... aten.clone.default ... is not an allowed operator type`. - **vla_jepa** fails in `to_executorch` with ``AttributeError: `_prop_tensor_constant551` is not a buffer``. - **vla_jepa and lawam** fail at run time with `[Gather] Boolean indices not supported`, and an int64 `scatter_add` with `[ScatterAxis::eval_gpu] Does not support int64`. - A policy with hundreds of delegates spends about an hour in `to_backend`, then its memory grows until it is stopped. ### Fix - `constant_prop_pass` gives an ATen or training program `torch.ops.aten.clone.default` in front of a folded output, not an Edge clone. - `duplicate_constant_node` gave every user of a shared constant but one its own copy, so one program could list the same constant twice, and such a program cannot be unlifted. Now users with the same delegation tag, or none, share one placeholder. **Behavior change:** a graph without tags gets no copies, so callers must tag users first. The MLX builder now rejects two placeholders of one constant. - `to_backend` copies the shared constants of all delegates in one pass, and the result is the same on every run. - Fusing a partition sorts the graph in place, so earlier partitions are not recompiled. **Behavior change:** `create_submodule_from_nodes` no longer recompiles `gm`, so call `gm.recompile()` before you run `gm`. - MLX delegates share constant tensors while lowering, and every delegate that uses a weight refers to the same bytes. - The MLX index handler replaces a constant boolean mask with its integer positions, and other boolean indices stay on the CPU. The `scatter_add` handler leaves 8-byte types, such as int64, on the CPU. **Follow-ups to #24404:** - `constant_prop_pass` makes a folded zero-stride constant contiguous again. Since #24404 it could stay channels-last, and portable `add` then failed at run time with a contiguous input (`different dim orders`). - `VgfPartitioner` with `alias_buffer_mutations=True` raises a `ValueError` when `tag_mutated_buffer` keeps a mutated buffer in the owning program, since it can only alias a buffer the delegate owns. Before, it set the aliasing option anyway, with only a warning. - The CUDA and Metal partitioners now give a mutated buffer that no op reads to the delegate that writes its new value. When that value comes from outside the delegates, as in `self.state.copy_(x)`, the buffer stays in the owning program, where before lowering failed with `Buffer output x does not point to a buffer that exists`. - The CUDA overview and the `skip_h2d_for_method_inputs` error say when `tag_mutated_buffer` keeps a buffer out. ### Testing - Each code fix has a new test that fails without it, and the exir and MLX tests have no new failures. - On synthetic graphs, copying 400 shared constants takes 0.23 s instead of 71 s, fusing 500 partitions 3 s instead of 126 s, and weights shared by 10 delegates peak at 2.2 GiB instead of 9.2 GiB, with the same program. - vla_jepa and lawam now lower to MLX, and lingbot_vla_v2 exports with a 32.6 GB peak. --- backends/aoti/aoti_partitioner.py | 25 ++- .../vgf/backends-arm-vgf-overview.md.in | 3 +- backends/arm/test/misc/test_compile_spec.py | 32 +++ backends/arm/vgf/partitioner.py | 17 ++ .../tests/test_replace_complex_with_real.py | 8 +- backends/cuda/tests/test_cuda_partitioner.py | 81 +++++++ backends/mlx/builder/program_builder.py | 74 ++++--- backends/mlx/ops.py | 66 +++++- backends/mlx/preprocess.py | 50 ++++- backends/mlx/test/test_partitioner.py | 203 +++++++++++++++++- ...test_create_delete_constant_placeholder.py | 4 + .../backends/arm-vgf/arm-vgf-overview.md | 3 +- docs/source/backends/cuda/cuda-overview.md | 13 +- exir/backend/backend_api.py | 14 +- .../duplicate_constant_node_pass.py | 91 ++++---- exir/backend/test/test_passes.py | 17 +- exir/backend/test/test_utils.py | 171 +++++++++++++++ exir/backend/utils.py | 20 +- exir/emit/test/test_emit.py | 23 +- exir/lowered_backend_module.py | 37 +++- exir/passes/constant_prop_pass.py | 29 ++- exir/passes/propagate_device_pass.py | 3 +- exir/tests/test_delegate.py | 35 +++ exir/tests/test_passes.py | 29 +++ 24 files changed, 930 insertions(+), 118 deletions(-) diff --git a/backends/aoti/aoti_partitioner.py b/backends/aoti/aoti_partitioner.py index f84febbdc24..0a1a29b3040 100644 --- a/backends/aoti/aoti_partitioner.py +++ b/backends/aoti/aoti_partitioner.py @@ -100,13 +100,18 @@ def is_node_supported( node.meta["delegation_tag"] = user.meta["delegation_tag"] break - tag_constant_data(exported_program) - tag_mutated_buffer(exported_program) - # tag_constant_data only tags constants that have users; tag the - # genuinely unused ones too so none are left dangling. + # genuinely unused ones too so none are left dangling. A mutated buffer + # gets the tag of the node that writes its new value, so it stays out + # when that value comes from outside the delegates, as in + # self.state.copy_(x). if partition_tags: fallback_tag = next(iter(partition_tags)) + signature = exported_program.graph_signature + writers = { + buffer: output for output, buffer in signature.buffers_to_mutate.items() + } + nodes = {node.name: node for node in exported_program.graph.nodes} for node in exported_program.graph.nodes: if ( node.op == "placeholder" @@ -118,7 +123,17 @@ def is_node_supported( or is_lifted_tensor_constant(exported_program, node) ) ): - node.meta["delegation_tag"] = fallback_tag + writer = writers.get(signature.inputs_to_buffers.get(node.name)) + tag = ( + fallback_tag + if writer is None + else nodes[writer].meta.get("delegation_tag") + ) + if tag is not None: + node.meta["delegation_tag"] = tag + + tag_constant_data(exported_program) + tag_mutated_buffer(exported_program) return PartitionResult( tagged_exported_program=exported_program, partition_tags=partition_tags diff --git a/backends/arm/scripts/docgen/vgf/backends-arm-vgf-overview.md.in b/backends/arm/scripts/docgen/vgf/backends-arm-vgf-overview.md.in index 8ce968760aa..231279b6762 100644 --- a/backends/arm/scripts/docgen/vgf/backends-arm-vgf-overview.md.in +++ b/backends/arm/scripts/docgen/vgf/backends-arm-vgf-overview.md.in @@ -42,7 +42,8 @@ The full user-facing API is documented below. For static mutable buffers such as KV caches, set `alias_buffer_mutations=True`. Eligible buffers must be zero-initialized `int8` tensors whose reads and mutation -are contained in one delegated partition. The backend aliases each buffer's +are contained in one delegated partition, and whose new value is not returned or +read outside that partition. The backend aliases each buffer's input and mutation output to persistent VGF storage across invocations. $COMPILE_SPEC diff --git a/backends/arm/test/misc/test_compile_spec.py b/backends/arm/test/misc/test_compile_spec.py index 7658884cdc0..20b39b13bf5 100644 --- a/backends/arm/test/misc/test_compile_spec.py +++ b/backends/arm/test/misc/test_compile_spec.py @@ -173,6 +173,7 @@ def fake_partition(_self, _exported_program): monkeypatch.setattr(TOSAPartitioner, "partition", fake_partition) monkeypatch.setattr(partitioner, "_validate_mutable_buffers", lambda _: None) monkeypatch.setattr(vgf_partitioner, "tag_mutated_buffer", lambda _: None) + monkeypatch.setattr(partitioner, "_check_mutable_buffers_owned", lambda _: None) result = partitioner.partition(tagged_program) tag0_specs = result.partition_tags["tag0"].compile_specs @@ -193,6 +194,37 @@ def fake_partition(_self, _exported_program): assert all(spec.key != "mutable_buffer_pairs" for spec in shared_spec.compile_specs) +@mark.parametrize("returned", [False, True]) +def test_vgf_aliasing_rejects_a_buffer_the_delegate_cannot_own(monkeypatch, returned): + class MutatingCache(torch.nn.Module): + def __init__(self): + super().__init__() + self.register_buffer("cache", torch.zeros(4, dtype=torch.int8)) + + def forward(self, x): + self.cache.add_(x) + # A delegate cannot own a buffer whose new value is returned. + return self.cache if returned else self.cache + 1 + + partitioner = VgfPartitioner(VgfCompileSpec(alias_buffer_mutations=True)) + + def tag_every_op(_self, program): + for node in program.graph.nodes: + if node.op == "call_function": + node.meta["delegation_tag"] = "tag0" + return PartitionResult(program, {"tag0": partitioner.delegation_spec}) + + monkeypatch.setattr(TOSAPartitioner, "partition", tag_every_op) + exported_program = torch.export.export( + MutatingCache(), (torch.ones(4, dtype=torch.int8),) + ).run_decompositions() + if returned: + with raises(ValueError, match="cannot be aliased"): + partitioner.partition(exported_program) + else: + partitioner.partition(exported_program) + + def test_vgf_direct_aliasing_requires_partitioner(monkeypatch): class MutatingCache(torch.nn.Module): diff --git a/backends/arm/vgf/partitioner.py b/backends/arm/vgf/partitioner.py index 34dfbacc657..90aa757493d 100644 --- a/backends/arm/vgf/partitioner.py +++ b/backends/arm/vgf/partitioner.py @@ -103,6 +103,22 @@ def _validate_mutable_buffers(self, exported_program: ExportedProgram) -> None: f"mutation tag={mutation_tag}, consumers=[{consumers}]." ) + def _check_mutable_buffers_owned(self, exported_program: ExportedProgram) -> None: + # tag_mutated_buffer also keeps a buffer out of the delegate when its + # new value is returned or read outside it, which + # _validate_mutable_buffers does not see. Aliasing needs the delegate + # to own the buffer. + signature = exported_program.graph_signature + mutated = set(signature.buffers_to_mutate.values()) + for node in exported_program.graph.nodes: + buffer_name = signature.inputs_to_buffers.get(node.name) + if buffer_name in mutated and "delegation_tag" not in node.meta: + raise ValueError( + f"VGF mutable buffer {buffer_name!r} cannot be aliased: its " + "new value is returned or read outside the delegated " + "partition." + ) + def partition(self, exported_program: ExportedProgram) -> PartitionResult: """Partition the program and tag TOSA-compatible subgraphs. @@ -133,4 +149,5 @@ def partition(self, exported_program: ExportedProgram) -> PartitionResult: } self._validate_mutable_buffers(result.tagged_exported_program) tag_mutated_buffer(result.tagged_exported_program) + self._check_mutable_buffers_owned(result.tagged_exported_program) return result diff --git a/backends/cuda/passes/tests/test_replace_complex_with_real.py b/backends/cuda/passes/tests/test_replace_complex_with_real.py index 793fe16555c..67f760c77ab 100644 --- a/backends/cuda/passes/tests/test_replace_complex_with_real.py +++ b/backends/cuda/passes/tests/test_replace_complex_with_real.py @@ -89,9 +89,8 @@ def forward(self, x, positions, scale): torch.testing.assert_close(program.module()(*inputs), model(*inputs)) def test_tensor_with_two_placeholders(self): - """Partitioning gives a tensor a placeholder per op that reads it when - another backend takes ops between those, and the pass stores the - tensor once.""" + """Partitioning gives a tensor a placeholder per delegate that reads it, + and the pass stores the tensor once.""" class Rotate(torch.nn.Module): def __init__(self): @@ -107,6 +106,9 @@ def forward(self, x, y): model = Rotate() inputs = (torch.randn(3, 2), torch.randn(3, 2)) program = _program_for_cuda_passes(model, inputs) + freqs = next(n for n in program.graph.nodes if n.name == "b_freqs") + for i, user in enumerate(freqs.users): + user.meta["delegation_tag"] = f"tag{i}" self.assertEqual( duplicate_constant_node(program, "b_freqs"), {"b_freqs_copy_0"} ) diff --git a/backends/cuda/tests/test_cuda_partitioner.py b/backends/cuda/tests/test_cuda_partitioner.py index 996ed2a20b8..159fdeeaa1f 100644 --- a/backends/cuda/tests/test_cuda_partitioner.py +++ b/backends/cuda/tests/test_cuda_partitioner.py @@ -33,6 +33,7 @@ parse_offgraph_kv_manifest, ring_physical_capacity, ) +from executorch.exir import to_edge from executorch.exir._serialize._cord import FileBackedData from executorch.exir._serialize._named_data_store import NamedDataStore from executorch.exir.backend.backend_details import PreprocessResult @@ -818,6 +819,86 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: f"Constant placeholder {node.name} has tag '{actual_tag}' but expected '{expected_tag}'", ) + def test_unread_mutated_buffer_copied_from_an_input_stays_out(self) -> None: + """ + A mutated buffer that no op reads stays in the owning program when its + new value is a method input, as in self.state.copy_(x). When the only + partition computes the new value, the buffer goes to that delegate. + """ + + class WriteState(torch.nn.Module): + def __init__(self, copy_input: bool) -> None: + super().__init__() + self.copy_input = copy_input + self.register_buffer("state", torch.zeros(2)) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + self.state.copy_(x if self.copy_input else x + x) + return torch.sin(x) + + for copy_input in (False, True): + # to_edge removes the copy, so the new value comes straight from x or x + x. + edge = to_edge(export(WriteState(copy_input), (torch.randn(2),))) + result = CudaPartitioner([]).partition(edge.exported_program()) + program = result.tagged_exported_program + state = next( + n + for n in program.graph.nodes + if n.op == "placeholder" and is_buffer(program, n) + ) + tag = state.meta.get("delegation_tag") + if copy_input: + self.assertIsNone(tag) + else: + self.assertIn(tag, result.partition_tags) + + def test_unread_mutated_buffer_goes_to_the_partition_that_writes_it( + self, + ) -> None: + """ + With several partitions, a mutated buffer that no op reads goes to the + delegate whose op computes its new value, even when that partition is + not the one listed first. + """ + + class WriteState(torch.nn.Module): + def __init__(self) -> None: + super().__init__() + self.register_buffer("state", torch.zeros(2)) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + s = x + x + self.state.copy_(s) + return torch.cos(s * 3) + + program = to_edge(export(WriteState(), (torch.randn(2),))).exported_program() + graph = program.graph_module.graph + nodes = {n.name: n for n in graph.nodes} + add, mul, cos = ( + nodes[name] + for name in ("aten_add_tensor", "aten_mul_tensor", "aten_cos_default") + ) + # A delegate of another backend between mul and cos splits this + # backend's ops into two partitions. + program.graph_module.lowered_module_0 = torch.nn.Module() + with graph.inserting_before(cos): + lowered = graph.get_attr("lowered_module_0") + delegate = graph.call_function(executorch_call_delegate, (lowered, mul)) + output = graph.call_function(operator.getitem, (delegate, 0)) + cos.replace_input_with(mul, output) + graph.lint() + + result = CudaPartitioner([]).partition(program) + state = next( + n + for n in program.graph.nodes + if n.op == "placeholder" and is_buffer(program, n) + ) + self.assertNotEqual( + add.meta["delegation_tag"], next(iter(result.partition_tags)) + ) + self.assertEqual(state.meta.get("delegation_tag"), add.meta["delegation_tag"]) + def test_does_not_retag_already_lowered_delegate(self) -> None: """ A node already lowered by a previous partitioner appears as an diff --git a/backends/mlx/builder/program_builder.py b/backends/mlx/builder/program_builder.py index d5ab4124579..2a1c855171e 100644 --- a/backends/mlx/builder/program_builder.py +++ b/backends/mlx/builder/program_builder.py @@ -59,7 +59,11 @@ Tid, Vid, ) -from executorch.exir._serialize._named_data_store import NamedDataStore +from executorch.exir._serialize._named_data_store import ( + _tensor_to_bytes, + NamedDataStore, +) +from executorch.exir.tensor_layout import TensorLayout from torch.export.exported_program import ExportedProgram from torch.fx.node import Node from torch.utils import _pytree as pytree @@ -316,8 +320,10 @@ def release_placeholder_tensor(self, node: Node) -> None: A repacking handler replaces a raw weight with new constants, leaving the original dead. It would otherwise stay in the ExportedProgram until get_named_data_store() sweeps unused constants -- which only runs after - the whole graph is built, so every raw weight and every repacked weight - are live simultaneously (+16 GB on a 16 GB Q4_K model). + the whole graph is built. Dropping it frees the raw weight only when + nothing else holds it: during lowering, the delegate's program shares + its tensors with the owning program (see + MLXBackend.copy_exported_program_for_preprocess). Only call this when the raw data is fully consumed: the lowering must not reference the placeholder's slot in any emitted instruction, or it @@ -520,21 +526,21 @@ def _make_io_slots(self): # noqa: C901 else: raise NotImplementedError(f"Support for input {arg} is not implemented") - target_to_constant_slot = {} + constant_targets = set() for node in self.ep.graph.nodes: if node.op == "placeholder": if node.users == {}: continue if node.name in constant_tensors: - # A constant shared with other partitions is duplicated once per - # user, so several placeholders here can name the same target. + # Constant data is named by target, so a second slot would get none. target = constant_tensors[node.name] - if target in target_to_constant_slot: - self.set_slot(node, target_to_constant_slot[target]) - else: - target_to_constant_slot[target] = self.make_or_get_slot( - node, id_space=IdSpace.Constant + if target in constant_targets: + raise ValueError( + f"constant '{target}' has more than one placeholder, " + f"'{node.name}' is a second one" ) + constant_targets.add(target) + self.make_or_get_slot(node, id_space=IdSpace.Constant) elif node.name in user_inputs: val = node.meta.get("val", None) if isinstance(val, torch.Tensor) and not val.is_contiguous(): @@ -1132,20 +1138,29 @@ def _build_mlx_graph(self) -> MLXGraph: tensor_meta=tensor_meta_list, ) - def get_named_data_store(self) -> NamedDataStore: + def get_named_data_store( + self, constant_bytes: Dict[int, Tuple[torch.Tensor, bytes]] + ) -> NamedDataStore: """ Get a NamedDataStore containing all constant tensors. + ``constant_bytes`` maps the id of a tensor of the program to the tensor + and its bytes. Delegates that share a constant read the same tensor, so + passing one map to all of them stores its bytes once rather than once + per delegate. The map holds the tensor so that its id stays unique, and + takes only the program's tensors, which outlive the lowering anyway, + not the constants a handler creates. + Uses the unprefixed canonical-name → Slot mapping built by ``_build_io_maps()`` so that tensor lookups hit ``ep.state_dict`` / ``ep.constants`` / ``extra_constants`` (which all use unprefixed keys). The prefix is applied at the exit boundary — the ``NamedDataStore`` key — so it matches the FlatBuffer ``named_slots``. - To reduce peak memory, each constant is deleted from the EP - immediately after its bytes are added to the NamedDataStore. - This avoids holding two full copies of all constants simultaneously - (important for large models where constants can be 20+ GB). + Each constant is deleted from the EP immediately after its bytes are + added to the NamedDataStore. This frees constants a handler created. A + tensor of the program stays alive, held by ``constant_bytes`` (and, + during lowering, by the owning program). """ named_data_store = NamedDataStore() @@ -1155,7 +1170,8 @@ def get_named_data_store(self) -> NamedDataStore: key=lambda x: self._slot_to_final_tid.get(x[1], 0), ) - # Free EP constants not used by the MLX graph to reduce peak memory. + # Drop EP constants not used by the MLX graph; this frees only those + # that nothing else holds. used = set(self._constant_name_to_slot.keys()) for ispec in self.ep.graph_signature.input_specs: if ispec.arg.name in used and ispec.target is not None: @@ -1166,6 +1182,12 @@ def get_named_data_store(self) -> NamedDataStore: if name not in used and isinstance(d[name], torch.Tensor): del d[name] + program_tensors = { + id(tensor) + for d in (self.ep._state_dict, self.ep._constants) + for tensor in d.values() + } + logger.debug(f"Adding {len(entries)} constants to NamedDataStore...") for canonical_name, _slot in entries: tensor = self._find_constant_tensor(canonical_name) @@ -1181,17 +1203,21 @@ def get_named_data_store(self) -> NamedDataStore: ) t = tensor.detach().cpu().contiguous() + data, layout = t, None + if id(tensor) in program_tensors: + if id(tensor) not in constant_bytes: + constant_bytes[id(tensor)] = (tensor, _tensor_to_bytes(t)) + data = constant_bytes[id(tensor)][1] + layout = TensorLayout.from_tensor(t) named_data_store.add_named_data( key=self._prefix_key(canonical_name), - data=t, + data=data, alignment=16, + tensor_layout=layout, ) - # Free the original tensor from the EP immediately. - # The contiguous copy is now serialized as bytes in the - # NamedDataStore — the EP reference is no longer needed. - # (It would be deleted by lowered_backend_module.py after - # preprocess() returns anyway.) + # The bytes are in the NamedDataStore, so the EP reference is no + # longer needed. This frees the tensor only if nothing else holds it. self._delete_constant_tensor(canonical_name) del tensor, t @@ -1231,7 +1257,7 @@ def _find_constant_tensor(self, name: str) -> Optional[torch.Tensor]: return d[k] def _delete_constant_tensor(self, name: str) -> None: - """Delete a constant from the EP to free memory during serialization.""" + """Delete a constant from the EP; this frees it only if nothing else does.""" result = self._resolve_constant(name) if result: diff --git a/backends/mlx/ops.py b/backends/mlx/ops.py index 5db12feef82..b4704067402 100644 --- a/backends/mlx/ops.py +++ b/backends/mlx/ops.py @@ -2406,6 +2406,53 @@ def _roll_handler(P: MLXProgramBuilder, n: Node) -> Slot: return out +def _constant_mask_as_positions( + P: MLXProgramBuilder, n: Node, non_none: List[Tuple[int, Slot, Any]] +) -> List[Tuple[int, Slot, Any]]: + """Replace a constant boolean index of aten.index.Tensor with its positions. + + The MLX runtime gathers only with integer indices. A boolean mask that is + a constant of the program, used as the only index, becomes one integer + index per mask dimension. Any other boolean index is refused, so the CPU + runs it. + """ + masks = [ + (axis, idx) + for axis, idx, val in non_none + if isinstance(val, torch.Tensor) and val.dtype == torch.bool + ] + if not masks: + return non_none + axis, mask = masks[0] + mask_node = n.args[1][axis] + # The partitioner sees parameters and persistent buffers as fake tensors, + # so only a mask in ep.constants has values it can read. + if ( + len(non_none) > 1 + or mask.id_space != IdSpace.Constant + or (target := P.get_placeholder_target(mask_node)) not in P.ep.constants + ): + raise NotImplementedError( + "aten.index.Tensor: the MLX runtime gathers only with integer " + "indices, so only a constant boolean mask used as the only index " + "is lowered" + ) + value = P.ep.constants[target] + if not value.any(): + raise NotImplementedError( + "aten.index.Tensor: an all-false mask selects nothing, and the MLX " + "runtime cannot write an empty output" + ) + return [ + ( + axis + i, + P.make_or_get_constant(f"{target}_positions_{i}", positions), + positions, + ) + for i, positions in enumerate(value.nonzero(as_tuple=True)) + ] + + @REGISTRY.register(target=[torch.ops.aten.index.Tensor]) def _index_handler(P: MLXProgramBuilder, n: Node) -> Slot: args = P.args(n) @@ -2422,14 +2469,19 @@ def _index_handler(P: MLXProgramBuilder, n: Node) -> Slot: x_ndim = len(x_meta.shape) if x_meta is not None else None # Filter out None indices and track which axes they correspond to - non_none = [(i, idx) for i, idx in enumerate(idx_list) if idx is not None] + non_none = [ + (i, idx, n.args[1][i].meta.get("val")) + for i, idx in enumerate(idx_list) + if idx is not None + ] if len(non_none) == 0: raise ValueError("aten.index.Tensor: all indices are None") + non_none = _constant_mask_as_positions(P, n, non_none) + if len(non_none) == 1: - axis, idx = non_none[0] - idx_meta = n.args[1][axis].meta.get("val") + axis, idx, idx_meta = non_none[0] ndim_match = ( x_meta is not None and idx_meta is not None @@ -2464,8 +2516,8 @@ def _index_handler(P: MLXProgramBuilder, n: Node) -> Slot: "aten.index.Tensor with multiple indices requires input shape metadata" ) - indices = [P.slot_to_tid(idx) for _, idx in non_none] - axes = [i for i, _ in non_none] + indices = [P.slot_to_tid(idx) for _, idx, _ in non_none] + axes = [i for i, _, _ in non_none] # slice_sizes: 1 for indexed axes, full static size for non-indexed axes. indexed_axes = set(axes) @@ -2619,6 +2671,10 @@ def _scatter_add_handler(P: MLXProgramBuilder, n: Node) -> Slot: args = P.args(n) require_args(args, 4, 4, "aten.scatter_add") require_kwargs(P.kwargs(n), set(), "aten.scatter_add") + dtype = n.meta["val"].dtype + # MLX's Metal scatter kernels reject 8-byte element types at run time. + if dtype.itemsize == 8: + raise NotImplementedError(f"aten.scatter_add on {dtype} is not supported") x, dim, indices, src = args out = P.make_or_get_slot(n) P.emit( diff --git a/backends/mlx/preprocess.py b/backends/mlx/preprocess.py index b2e26a4d2b2..ac0b583810d 100644 --- a/backends/mlx/preprocess.py +++ b/backends/mlx/preprocess.py @@ -18,9 +18,11 @@ from __future__ import annotations +import copy import hashlib -from typing import ClassVar, final, List +from typing import ClassVar, Dict, final, List, Tuple +import torch from executorch.backends.mlx._logging import logger from executorch.backends.mlx._memprofile import log_footprint, mem_phase from executorch.backends.mlx.builder.program_builder import MLXProgramBuilder @@ -57,6 +59,42 @@ class MLXBackend(BackendDetails): EXPECTED_MAGIC: ClassVar[bytes] = MAGIC EXPECTED_LENGTH: ClassVar[int] = HEADER_LENGTH + @classmethod + def copy_exported_program_for_preprocess( + cls, edge_program: ExportedProgram, compile_specs: List[CompileSpec] + ) -> ExportedProgram: + """Copy the graph but share the constant tensors. + + exir copies the program of every delegate before it preprocesses any of + them, so a deep copy holds all the weights twice, and a constant shared + by several delegates once more for each of them. preprocess only drops + constants from the copy's tables and never writes to a tensor, so + sharing them is safe. + """ + tensors = { + id(tensor): tensor + for values in (edge_program.state_dict, edge_program.constants) + for tensor in values.values() + if isinstance(tensor, torch.Tensor) + } + return copy.deepcopy(edge_program, tensors) + + @classmethod + def preprocess_multimethod( + cls, + edge_programs: Dict[str, List[ExportedProgram]], + compile_specs: Dict[str, List[List[CompileSpec]]], + ) -> Dict[str, List[PreprocessResult]]: + """Preprocess every delegate, sharing the bytes of the constants they share.""" + constant_bytes: Dict[int, Tuple[torch.Tensor, bytes]] = {} + return { + method: [ + cls._preprocess(program, specs, constant_bytes) + for program, specs in zip(programs, compile_specs[method], strict=True) + ] + for method, programs in edge_programs.items() + } + @staticmethod def preprocess( edge_program: ExportedProgram, @@ -73,6 +111,14 @@ def preprocess( PreprocessResult containing the serialized MLX program and data_store_output with constant tensor data. """ + return MLXBackend._preprocess(edge_program, compile_specs, {}) + + @staticmethod + def _preprocess( + edge_program: ExportedProgram, + compile_specs: List[CompileSpec], + constant_bytes: Dict[int, Tuple[torch.Tensor, bytes]], + ) -> PreprocessResult: logger.debug("MLXBackend.preprocess() called") logger.debug(f"Edge program:\n{edge_program}") log_footprint("preprocess entry") @@ -91,7 +137,7 @@ def preprocess( # Get constant data as NamedDataStore (ET will own this data) with mem_phase("get_named_data_store"): - named_data_store = builder.get_named_data_store() + named_data_store = builder.get_named_data_store(constant_bytes) logger.debug( f" named_data_store entries: {len(named_data_store.pte_data)}" diff --git a/backends/mlx/test/test_partitioner.py b/backends/mlx/test/test_partitioner.py index 5897fe58bee..32720e76a1e 100644 --- a/backends/mlx/test/test_partitioner.py +++ b/backends/mlx/test/test_partitioner.py @@ -20,8 +20,11 @@ from executorch.backends.mlx.passes import get_default_passes from executorch.backends.mlx.test.test_utils import get_mlx_node_counts from executorch.exir import EdgeCompileConfig, to_edge, to_edge_transform_and_lower +from executorch.exir.lowered_backend_module import LoweredBackendModule +from executorch.exir.program import get_fake_program from executorch.runtime import Runtime from torch.export import export +from torch.export.graph_signature import InputKind, InputSpec, TensorArgument class TestMLXPartitionerRejectsToEdge(unittest.TestCase): @@ -79,7 +82,9 @@ def _run(model, inputs, transform_passes=None): counts = get_mlx_node_counts(path) method = Runtime.get().load_program(path).load_method("forward") out = method.execute(list(inputs))[0] - return counts, delegates, (out - ref).abs().max().item() + assert out.shape == ref.shape, (out.shape, ref.shape) + diff = (out - ref).abs() + return counts, delegates, diff.max().item() if diff.numel() else 0.0 class TestMLXPartitionerConstantInputs(unittest.TestCase): @@ -163,6 +168,22 @@ def forward(self, x, y): self.assertGreater(delegates, 0) self.assertLess(err, 1e-6) + def test_int64_scatter_add_stays_off_the_delegate(self): + class ScatterAdd(nn.Module): + def forward(self, base, index, src): + return base.scatter_add(0, index, src) + 1 + + index = torch.tensor([0, 2, 2, 7]) + for dtype, scatters in ((torch.float32, 1), (torch.int64, 0)): + with self.subTest(dtype=dtype): + counts, delegates, err = _run( + ScatterAdd(), + (torch.zeros(8, dtype=dtype), index, torch.ones(4, dtype=dtype)), + ) + self.assertEqual(counts.get("ScatterAddNode", 0), scatters) + self.assertGreater(delegates, 0) + self.assertEqual(err, 0) + class Sdpa(nn.Module): def __init__(self, is_causal: bool = False): @@ -353,6 +374,66 @@ def forward(self, x): self.assertGreater(delegates, 1) self.assertLess(err, 1e-5) + def test_second_placeholder_for_one_constant_raises(self): + class TwoReads(nn.Module): + def __init__(self): + super().__init__() + self.register_buffer("c", torch.randn(4)) + + def forward(self, x): + return x + self.c, x * self.c + + ep = export(TwoReads(), (torch.randn(4),), strict=False) + original = next(n for n in ep.graph.nodes if n.name == "b_c") + with ep.graph.inserting_after(original): + second = ep.graph.placeholder("b_c_second") + second.meta = dict(original.meta) + mul = next(n for n in ep.graph.nodes if n.target == torch.ops.aten.mul.Tensor) + mul.replace_input_with(original, second) + ep.graph_signature.input_specs.insert( + 1, InputSpec(InputKind.BUFFER, TensorArgument("b_c_second"), "c", True) + ) + + for check_only in (True, False): + with self.subTest(check_only=check_only): + builder = MLXProgramBuilder(ep) + with self.assertRaisesRegex(ValueError, "more than one placeholder"): + if check_only: + builder.check_support_only() + else: + builder.build() + + def test_delegates_share_the_bytes_of_a_shared_weight(self): + class SharedLinear(nn.Module): + def __init__(self): + super().__init__() + self.linear = nn.Linear(4, 4) + + def forward(self, x): + # elu is not lowered, so the second use lands in another delegate. + return self.linear(torch.nn.functional.elu(self.linear(x))) + + model = SharedLinear().eval() + weight = model.linear.weight.detach().clone() + program = to_edge_transform_and_lower( + export(model, (torch.randn(2, 4),), strict=False), + partitioner=[MLXPartitioner()], + ).exported_program() + delegates = [ + module + for module in program.graph_module.children() + if isinstance(module, LoweredBackendModule) + ] + + self.assertEqual(len(delegates), 2) + first, second = ( + {id(buffer) for buffer in delegate.named_data_store_output.buffers} + for delegate in delegates + ) + self.assertEqual(len(first), 2) + self.assertEqual(first, second) + self.assertTrue(torch.equal(model.linear.weight, weight)) + class TestMLXPartitionerGroupedKeys(unittest.TestCase): """Repeated KV heads are fused only when the caller enables the shared passes.""" @@ -443,6 +524,126 @@ def forward(self, x): self.assertLess(err, 1e-6) +class TestMLXPartitionerBoolIndex(unittest.TestCase): + """The MLX runtime gathers only with integer indices.""" + + class ConstantMask(nn.Module): + def __init__(self, mask, persistent=None, axis=0, scale=True): + super().__init__() + if persistent is None: + self.mask = mask + else: + self.register_buffer("mask", mask, persistent=persistent) + self.axis = axis + self.scale = scale + + def forward(self, x): + y = (x + 1)[(slice(None),) * self.axis + (self.mask,)] + if self.axis: + # The leading dim's stride depends on how many elements the + # mask selects, which exir spec propagation rejects, so keep it + # in the delegate. + y = y.sum(self.axis) + return y * 2 if self.scale else y + + def test_constant_mask_becomes_positions(self): + mask = torch.tensor([1, 0, 1, 1, 0, 1, 0, 1]) == 1 + cases = { + "1d": (self.ConstantMask(mask), (8, 16)), + "2d": (self.ConstantMask(mask.reshape(1, 8)), (1, 8, 16)), + "3d": (self.ConstantMask(mask.reshape(2, 2, 2)), (2, 2, 2, 5)), + "buffer": (self.ConstantMask(mask, persistent=False), (8, 16)), + "axis_1": (self.ConstantMask(mask.reshape(2, 4), axis=1), (3, 2, 4, 5)), + } + for name, (model, shape) in cases.items(): + with self.subTest(name): + _, delegates, err = _run(model.eval(), (torch.randn(shape),)) + self.assertEqual(delegates, 1) + self.assertLess(err, 1e-5) + + def test_other_bool_indices_run_on_the_cpu(self): + class ComputedMask(nn.Module): + def __init__(self): + super().__init__() + self.ids = torch.tensor([[1, 7, 7, 2, 7, 3, 7, 4]]) + + def forward(self, x): + return (x + 1)[self.ids == 7] * 2 + + class MaskInput(nn.Module): + def forward(self, x, mask): + return (x + 1)[mask] * 2 + + class MaskWithIndex(nn.Module): + def __init__(self, mask): + super().__init__() + self.mask = mask + + def forward(self, x, i): + return (x + 1)[self.mask, i] * 2 + + class MaskThenUpdate(nn.Module): + def __init__(self, mask): + super().__init__() + self.register_buffer("mask", mask, persistent=False) + + def forward(self, x): + y = (x + 1)[self.mask] * 2 + self.mask.copy_(x[:, :, 0] > 0) + return y + + mask = torch.tensor([[1, 0, 1, 1, 0, 1, 0, 1]]) == 1 + x = torch.randn(1, 8, 16) + # An all-false mask gives an empty result, so it is the graph output + # here: an MLX op after it would have to write an empty output too. + all_false = self.ConstantMask(torch.zeros(1, 8, dtype=torch.bool), scale=False) + cases = { + "computed_mask": (ComputedMask(), (x,), 2), + "mask_input": (MaskInput(), (x, mask), 2), + "mask_with_index": (MaskWithIndex(mask), (x, torch.tensor([3])), 2), + "persistent_buffer": (self.ConstantMask(mask, persistent=True), (x,), 2), + "all_false": (all_false, (x,), 1), + } + for name, (model, inputs, expected) in cases.items(): + with self.subTest(name): + _, delegates, err = _run(model.eval(), inputs) + self.assertEqual(delegates, expected) + self.assertLess(err, 1e-6) + + with self.subTest("mutated_buffer"): + # Not run: the eager reference call would overwrite the mask first. + program = _lower(MaskThenUpdate(mask).eval(), (x,)) + self.assertEqual(_delegate_count(program), 2) + + def test_handler_refuses_masks_it_cannot_lower(self): + mask = torch.tensor([1, 0, 1, 1, 0, 1, 0, 1]) == 1 + cases = { + # A persistent buffer has no values in the fake copy, so the handler + # must refuse it rather than fail reading it. + "persistent_buffer": ( + self.ConstantMask(mask, persistent=True), + "only a constant boolean mask", + ), + "all_false": ( + self.ConstantMask(torch.zeros(8, dtype=torch.bool)), + "all-false", + ), + } + for name, (model, reason) in cases.items(): + with self.subTest(name): + # The same fake copy of the program that to_backend gives the + # partitioner. + ep = get_fake_program(export(model.eval(), (torch.randn(8, 16),))) + index = next( + n for n in ep.graph.nodes if n.target == torch.ops.aten.index.Tensor + ) + builder = MLXProgramBuilder(ep) + builder.check_support_only() + info = builder.node_info[index] + self.assertFalse(info.supported) + self.assertIn(reason, info.unsupported_reason) + + class TestMLXPartitionerMixedSupport(unittest.TestCase): """An operator is preserved from decomposition per operator, not per call.""" diff --git a/backends/transforms/test/test_create_delete_constant_placeholder.py b/backends/transforms/test/test_create_delete_constant_placeholder.py index b85a14b036f..d805b4d23f5 100644 --- a/backends/transforms/test/test_create_delete_constant_placeholder.py +++ b/backends/transforms/test/test_create_delete_constant_placeholder.py @@ -211,6 +211,10 @@ def forward(self, x): export(SharedWeight(), (torch.zeros(1),), strict=True) ).exported_program() (weight_name,) = exported_program.graph_signature.inputs_to_parameters + weight = next(n for n in exported_program.graph.nodes if n.name == weight_name) + # A copy is made for a user in another delegate. + for i, user in enumerate(weight.users): + user.meta["delegation_tag"] = f"tag{i}" duplicate_constant_node(exported_program, weight_name) weight_nodes = [ diff --git a/docs/source/backends/arm-vgf/arm-vgf-overview.md b/docs/source/backends/arm-vgf/arm-vgf-overview.md index 2407074b099..142e8d05790 100644 --- a/docs/source/backends/arm-vgf/arm-vgf-overview.md +++ b/docs/source/backends/arm-vgf/arm-vgf-overview.md @@ -42,7 +42,8 @@ The full user-facing API is documented below. For static mutable buffers such as KV caches, set `alias_buffer_mutations=True`. Eligible buffers must be zero-initialized `int8` tensors whose reads and mutation -are contained in one delegated partition. The backend aliases each buffer's +are contained in one delegated partition, and whose new value is not returned or +read outside that partition. The backend aliases each buffer's input and mutation output to persistent VGF storage across invocations. ```python diff --git a/docs/source/backends/cuda/cuda-overview.md b/docs/source/backends/cuda/cuda-overview.md index 56dccf5813b..3f18d5cde6c 100644 --- a/docs/source/backends/cuda/cuda-overview.md +++ b/docs/source/backends/cuda/cuda-overview.md @@ -256,11 +256,14 @@ the delegate, so that the delegate reads the constant itself. `share_mutable_buf planned tensor in host memory, so it does not work with this flag. This flag works on its own: it needs neither `skip_h2d_for_method_inputs` nor unplanned inputs. -**`skip_h2d_for_method_inputs` refuses inputs that the method changes.** `to_executorch` fails if -the method changes an input, even when the delegate makes the change. This includes a mutable -buffer, such as a KV cache, even though the caller does not pass it. For a buffer, put it and every -operator that uses it in the delegate. The CUDA partitioner already moves the buffer in, with -`tag_mutated_buffer`, once those operators are there. +**`skip_h2d_for_method_inputs` refuses changed inputs that a delegate reads.** `to_executorch` fails +if the method changes an input that a delegate reads, even when the delegate makes the change. This +includes a mutable buffer, such as a KV cache, even though the caller does not pass it. For a +buffer, put it and every operator that uses it in the delegate. The CUDA partitioner already moves +the buffer in, with `tag_mutated_buffer`, once those operators are there. It keeps the buffer out +when the method also returns the buffer's new value, or when the new value is a method input, as in +`self.state.copy_(x)`. For a buffer that stays out, this flag refuses the method only if a delegate +reads it. **The choice is baked into the `.pte`.** A program exported with copies expects host tensors; one exported without them expects device tensors. Passing the wrong kind is a caller error, not diff --git a/exir/backend/backend_api.py b/exir/backend/backend_api.py index 8104595bfe1..bc59cb1434a 100644 --- a/exir/backend/backend_api.py +++ b/exir/backend/backend_api.py @@ -304,7 +304,7 @@ def _partition_and_lower_one_graph_module( # Copy the output node meta from the original output node, because # create_submodule_from_nodes doesn't cover the meta field submodule_output_node.meta = tagged_graph_module_output_node.meta - logging.debug(f"Partitioned graph module: {tagged_graph_module}") + logging.debug("Partitioned graph module: %s", tagged_graph_module.graph) ( submodule_program, @@ -423,8 +423,9 @@ def to_backend( update_to_real_program(tagged_exported_program, edge_program) - for tag, _ in partitioner_result.partition_tags.items(): - _maybe_duplicate_constant_nodes(tagged_exported_program, tag) + _maybe_duplicate_constant_nodes( + tagged_exported_program, partitioner_result.partition_tags.keys() + ) tagged_graph_module = _partition_and_lower( tagged_exported_program.graph_module, @@ -486,7 +487,7 @@ def _create_partitions_in_graph_module( submodule_output_node = submodule.graph.output_node() # Copy the output node meta from the original output node, because # create_submodule_from_nodes doesn't cover the meta field - logging.debug(f"Partitioned graph module: {tagged_graph_module}") + logging.debug("Partitioned graph module: %s", tagged_graph_module.graph) ( submodule_program, toplevel_input_specs_to_delete, @@ -750,8 +751,9 @@ def to_backend( update_to_real_program(tagged_exported_program, edge_program) - for tag, _ in partitioner_result.partition_tags.items(): - _maybe_duplicate_constant_nodes(tagged_exported_program, tag) + _maybe_duplicate_constant_nodes( + tagged_exported_program, partitioner_result.partition_tags.keys() + ) backend_id_to_call_submodule_nodes = _create_partitions( tagged_exported_program.graph_module, diff --git a/exir/backend/canonical_partitioners/duplicate_constant_node_pass.py b/exir/backend/canonical_partitioners/duplicate_constant_node_pass.py index 478c2f35eea..a6d66e3880c 100644 --- a/exir/backend/canonical_partitioners/duplicate_constant_node_pass.py +++ b/exir/backend/canonical_partitioners/duplicate_constant_node_pass.py @@ -6,31 +6,32 @@ import copy import logging -from typing import Optional, Set +from typing import Dict, List, Optional, Set import torch -from torch._export.utils import get_buffer, get_lifted_tensor_constant, get_param from torch.export import ExportedProgram -from torch.export.exported_program import InputSpec, TensorArgument +from torch.export.exported_program import InputKind, InputSpec, TensorArgument -def _get_attribute_or_constants( - exported_program: ExportedProgram, node: torch.fx.Node -) -> Optional[torch.Tensor]: - # get either attribute node or constant constant - maybe_param = get_param(exported_program, node) - maybe_buffer = get_buffer(exported_program, node) - maybe_lifted_tensor = get_lifted_tensor_constant(exported_program, node) - - constant_or_attribute = None - if maybe_param is not None: - constant_or_attribute = maybe_param - elif maybe_buffer is not None: - constant_or_attribute = maybe_buffer - elif maybe_lifted_tensor is not None: - constant_or_attribute = maybe_lifted_tensor - return constant_or_attribute +def _users_reading_a_copy( + node: torch.fx.Node, order: Dict[torch.fx.Node, int] +) -> List[List[torch.fx.Node]]: + """ + The users of node that read a copy of it, in groups that share one. The + users with the same delegation tag, or with none, form a group, so each + program takes a constant once: for example, a program that lists the + same lifted constant twice fails to unlift. The group with the graph + output keeps the original, which the output specs name, and otherwise + the group of the first user in graph order does. + """ + groups: Dict[Optional[str], List[torch.fx.Node]] = {} + # Rewiring a user for another constant moves it to the end of node.users. + for user in sorted(node.users, key=order.__getitem__): + groups.setdefault(user.meta.get("delegation_tag"), []).append(user) + output = any(user.op == "output" for user in node.users) + keeper = None if output else next(iter(groups), None) + return [users for tag, users in groups.items() if tag != keeper] # TODO: add other passes to duplicate call_function nodes @@ -41,6 +42,8 @@ def duplicate_constant_node( A pass to duplicate the attributes/constants node (the candidate_node) in the graph. Mostly used for duplicating light-weight data. If the data is too large, try tag it with "no_copy" to prevent high memory usage and make it as part of the output. A mutated buffer is never copied, because a copy would not see its updates: this raises instead. + Users with the same delegation tag, or with none, share one placeholder, so tag the + users before calling it: on a graph without tags it makes no copies. Args: exported_program: the exported program to be modified. If constants nodes are copied, they will be added as new @@ -50,17 +53,30 @@ def duplicate_constant_node( Returns: The set of the names of the new constant nodes """ - to_be_copied = [ - node + return duplicate_constant_nodes(exported_program, {candidate_node}) + + +def duplicate_constant_nodes( + exported_program: ExportedProgram, candidate_nodes: Set[str] +) -> Set[str]: + """ + duplicate_constant_node for several constant nodes at once. It recompiles + and validates the program once, rather than once per node, which matters + on a large graph with many shared constants. + + Returns: + The set of the names of the new constant nodes + """ + if not any( + node.op == "placeholder" and node.name in candidate_nodes for node in exported_program.graph.nodes - if node.name == candidate_node and node.op == "placeholder" - ] - if len(to_be_copied) == 0: + ): logging.info("no constant node to be copied") return set() new_input_specs = [] old_signature = exported_program.graph_signature copied_nodes = set() + order = {node: i for i, node in enumerate(exported_program.graph.nodes)} placeholder_idx = -1 for node in exported_program.graph.nodes: @@ -70,10 +86,15 @@ def duplicate_constant_node( placeholder_idx += 1 old_input_spec = old_signature.input_specs[placeholder_idx] old_input_spec_copy = copy.deepcopy(old_input_spec) - if node == to_be_copied[0]: + if node.name in candidate_nodes: constant_or_attribute_node = node - constant_or_attribute = _get_attribute_or_constants(exported_program, node) - if constant_or_attribute is None: + # The input spec kind answers this without get_param, get_buffer and + # get_lifted_tensor_constant, which rebuild a dict on every call. + if old_input_spec.kind not in ( + InputKind.PARAMETER, + InputKind.BUFFER, + InputKind.CONSTANT_TENSOR, + ): raise RuntimeError( f"placeholder node for non-params, non-buffer, and non-tensor constants should not be tagged: {node} " ) @@ -87,12 +108,7 @@ def duplicate_constant_node( "that reads or writes it has the same tag, as " "tag_mutated_buffer does, or leave it untagged." ) - # The graph output keeps the original, which the output specs - # name, so then every other user reads a copy. - users = [user for user in node.users if user.op != "output"] - if len(users) == len(node.users): - users = users[1:] - for ith, user in enumerate(users): + for ith, group in enumerate(_users_reading_a_copy(node, order)): copy_constant_or_attribute_fqn = node.name + f"_copy_{ith}" with exported_program.graph.inserting_before( constant_or_attribute_node @@ -113,10 +129,11 @@ def duplicate_constant_node( ) # This also reaches a constant that an op such as cat or # index_put reads from a list. - user.replace_input_with( - constant_or_attribute_node, - copied_constant_or_attribute_node, - ) + for user in group: + user.replace_input_with( + constant_or_attribute_node, + copied_constant_or_attribute_node, + ) # The copy keeps the original target, so it reads the # original tensor and needs no data of its own. new_input_specs.append( diff --git a/exir/backend/test/test_passes.py b/exir/backend/test/test_passes.py index 697f8c78d74..9a66ba3c298 100644 --- a/exir/backend/test/test_passes.py +++ b/exir/backend/test/test_passes.py @@ -34,12 +34,22 @@ def forward(self, x): ) const_nodes = [ - node.name + node for node in edge.exported_program().graph.nodes if node.op == "placeholder" and is_buffer(edge.exported_program(), node) ] + # Users in one program share the original. + self.assertEqual( + duplicate_constant_node(edge.exported_program(), const_nodes[0].name), + set(), + ) + # A copy is made for a user in another delegate. + for i, user in enumerate(const_nodes[0].users): + user.meta["delegation_tag"] = f"tag{i}" - copied_nodes = duplicate_constant_node(edge.exported_program(), const_nodes[0]) + copied_nodes = duplicate_constant_node( + edge.exported_program(), const_nodes[0].name + ) self.assertEqual(len(copied_nodes), 1) # Check that the new constant node is in the graph @@ -76,6 +86,9 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: user = users[1] user.args = tuple(a for a in user.args if a is not buffer_node) user.kwargs = {"other": buffer_node} + # A copy is made for a user in another delegate. + for i, u in enumerate(users): + u.meta["delegation_tag"] = f"tag{i}" # Patch validation since we modified the graph edge.exported_program()._validate = lambda: None diff --git a/exir/backend/test/test_utils.py b/exir/backend/test/test_utils.py index 24b72e9a1e4..a3b38bef16c 100644 --- a/exir/backend/test/test_utils.py +++ b/exir/backend/test/test_utils.py @@ -4,7 +4,9 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. +import itertools import unittest +from unittest.mock import patch import torch from executorch import exir @@ -24,6 +26,7 @@ ) from executorch.exir.backend.test.op_partitioner_demo import AddMulPartitionerDemo from executorch.exir.backend.utils import ( + _maybe_duplicate_constant_nodes, format_delegated_graph, get_delegated_payload, get_delegates, @@ -34,6 +37,7 @@ ) from executorch.exir.dialects._ops import bind_pattern_to_op, ops as exir_ops +from executorch.exir.lowered_backend_module import get_lowered_backend_modules from executorch.extension.pybindings.portable_lib import ( # @manual _get_registered_backend_names, _load_for_executorch_from_buffer, @@ -559,3 +563,170 @@ def partition(self, program: ExportedProgram) -> PartitionResult: program.graph.output_node().args[0][-1].name, "b_const" ) lowered.to_executorch() + + def test_to_backend_gives_each_program_one_copy_of_a_shared_constant(self): + class UseConst(torch.nn.Module): + def __init__(self, cpu_first, kind="constant"): + super().__init__() + const = torch.ones(2) + if kind == "buffer": + self.register_buffer("const", const) + else: + # A plain tensor attribute is lifted as a constant tensor. + self.const = ( + torch.nn.Parameter(const) if kind == "parameter" else const + ) + self.cpu_first = cpu_first + + def forward(self, x): + if self.cpu_first: + s, c = torch.sin(self.const), torch.cos(self.const) + y = (x + self.const) * self.const + z = (x - self.const) / self.const + if not self.cpu_first: + s, c = torch.sin(self.const), torch.cos(self.const) + return y, z, s, c + + class TagNodes(Partitioner): + def __init__(self, tags): + super().__init__() + self.tags = tags + + def partition(self, program: ExportedProgram) -> PartitionResult: + for node in program.graph.nodes: + if node.name in self.tags: + node.meta["delegation_tag"] = self.tags[node.name] + tag_constant_data(program) + return PartitionResult( + tagged_exported_program=program, + partition_tags=dict.fromkeys( + self.tags.values(), + DelegationSpec(ExecutorBackend.__name__, []), + ), + ) + + def counts(program): + programs = [program] + [ + module.original_module + for module in get_lowered_backend_modules(program.graph_module) + ] + return [ + [spec.target for spec in p.graph_signature.input_specs].count("const") + for p in programs + ] + + # Each delegate reads the constant twice, and sin and cos read it + # outside of them. Each program takes it once, whichever reads first. + for kind, cpu_first in itertools.product( + ("constant", "buffer", "parameter"), (False, True) + ): + with self.subTest(kind=kind, cpu_first=cpu_first): + lowered = to_edge( + export(UseConst(cpu_first, kind), (torch.ones(2),), strict=True) + ) + lowered = lowered.to_backend( + TagNodes({"aten_add_tensor": "tag0", "aten_mul_tensor": "tag0"}) + ) + self.assertEqual(counts(lowered.exported_program()), [1, 1]) + lowered.exported_program().module() + lowered = lowered.to_backend( + TagNodes({"aten_sub_tensor": "tag1", "aten_div_tensor": "tag1"}) + ) + self.assertEqual(counts(lowered.exported_program()), [1, 1, 1]) + lowered.to_executorch() + + # One partitioner takes both delegates, so one call copies it twice. + with self.subTest(one_partitioner=True): + lowered = to_edge(export(UseConst(False), (torch.ones(2),), strict=True)) + lowered = lowered.to_backend( + TagNodes( + { + "aten_add_tensor": "tag0", + "aten_mul_tensor": "tag0", + "aten_sub_tensor": "tag1", + "aten_div_tensor": "tag1", + } + ) + ) + self.assertEqual(counts(lowered.exported_program()), [1, 1, 1]) + lowered.to_executorch() + + def test_maybe_duplicate_constant_nodes_refuses_one_tag_string(self): + program = to_edge( + export(torch.nn.Linear(2, 2), (torch.ones(2),), strict=True) + ).exported_program() + # A str is an iterable of tags too, one per character. + with self.assertRaises(TypeError): + _maybe_duplicate_constant_nodes(program, "tag0") + + def test_constants_shared_by_many_delegates_are_copied_in_one_pass(self): + class ShareConsts(torch.nn.Module): + def __init__(self): + super().__init__() + self.register_buffer("a", torch.ones(2)) + self.register_buffer("b", torch.ones(2)) + + def forward(self, x): + return x + self.a, x * self.a, self.a - self.b, x / self.b + + program = to_edge( + export(ShareConsts(), (torch.ones(2),), strict=True) + ).exported_program() + ops = [node for node in program.graph.nodes if node.op == "call_function"] + for i, node in enumerate(ops): + node.meta["delegation_tag"] = f"tag{i}" + tag_constant_data(program) + + # Recompiling and validating once per shared constant is quadratic in + # the graph size, which a large model with many delegates cannot afford. + with patch.object( + ExportedProgram, + "_validate", + autospec=True, + side_effect=ExportedProgram._validate, + ) as validate, patch.object( + torch.fx.GraphModule, + "recompile", + autospec=True, + side_effect=torch.fx.GraphModule.recompile, + ) as recompile: + _maybe_duplicate_constant_nodes( + program, [node.meta["delegation_tag"] for node in ops] + ) + self.assertEqual(validate.call_count, 1) + self.assertEqual(recompile.call_count, 1) + + # Copying b_a rewires sub, which must not change who keeps b_b: the + # first reader in graph order does. + self.assertEqual( + { + node.meta["delegation_tag"]: [arg.name for arg in node.args] + for node in ops + }, + { + "tag0": ["x", "b_a"], + "tag1": ["x", "b_a_copy_0"], + "tag2": ["b_a_copy_1", "b_b"], + "tag3": ["x", "b_b_copy_0"], + }, + ) + for node in program.graph.nodes: + if node.op == "placeholder" and node.name != "x": + self.assertEqual( + {user.meta["delegation_tag"] for user in node.users}, + {node.meta["delegation_tag"]}, + ) + self.assertEqual( + { + spec.arg.name: spec.target + for spec in program.graph_signature.input_specs + }, + { + "b_a_copy_0": "a", + "b_a_copy_1": "a", + "b_a": "a", + "b_b_copy_0": "b", + "b_b": "b", + "x": None, + }, + ) diff --git a/exir/backend/utils.py b/exir/backend/utils.py index 96ff686cc2a..bc05f6e4037 100644 --- a/exir/backend/utils.py +++ b/exir/backend/utils.py @@ -16,7 +16,7 @@ import torch from executorch.exir.backend.backend_details import ExportedProgram from executorch.exir.backend.canonical_partitioners.duplicate_constant_node_pass import ( - duplicate_constant_node, + duplicate_constant_nodes, ) from executorch.exir.common import setting_python_recursive_limit from executorch.exir.delegate import executorch_call_delegate @@ -209,7 +209,7 @@ def _assign_new_tag( def _maybe_duplicate_constant_nodes( tagged_exported_program: ExportedProgram, - tag: str, + tags: Iterable[str], ) -> None: """ If the constants node is shared by different tagged nodes, like @@ -222,9 +222,13 @@ def _maybe_duplicate_constant_nodes( backend can estimate how much they want to duplicate the constant node, either error out or default to duplicate """ + if isinstance(tags, str): + raise TypeError("tags is an iterable of tags, not one tag") + tags = set(tags) candidate_nodes = set() for node in tagged_exported_program.graph.nodes: - if node.meta.get("delegation_tag", "") == tag: + tag = node.meta.get("delegation_tag", None) + if tag in tags: if node.op == "placeholder": for user in node.users: users_tag = user.meta.get("delegation_tag", None) @@ -236,12 +240,10 @@ def _maybe_duplicate_constant_nodes( ) else: candidate_nodes.add(node.name) - copied_nodes = set() - for candidate_node in candidate_nodes: - # Both tagged exported program and the owning program need to go through the same duplication pass - copied_nodes = copied_nodes.union( - duplicate_constant_node(tagged_exported_program, candidate_node) - ) + if not candidate_nodes: + return + # Both tagged exported program and the owning program need to go through the same duplication pass + copied_nodes = duplicate_constant_nodes(tagged_exported_program, candidate_nodes) candidate_node_with_copies = candidate_nodes.union(copied_nodes) _assign_new_tag(tagged_exported_program, candidate_node_with_copies) diff --git a/exir/emit/test/test_emit.py b/exir/emit/test/test_emit.py index 6ca6aee763c..cdc8d002696 100644 --- a/exir/emit/test/test_emit.py +++ b/exir/emit/test/test_emit.py @@ -2676,10 +2676,25 @@ def forward(self, x): # it contiguous. return x * self.scale.expand(1) - exported = constant_prop_pass( - export(ScaleByBuffer(), (torch.randn(4),), strict=True) - ) - self.assertEqual([t.stride() for t in exported.constants.values()], [(1,)]) + class AddStridedWeight(nn.Module): + def __init__(self): + super().__init__() + self.register_buffer("weight", torch.randn(48)) + + def forward(self, x): + # Channels-last but for a zero stride on the size-1 batch dim. + # The folded constant is contiguous, like the input it is added + # to, so portable add sees one dim order. + return x + self.weight.as_strided((1, 3, 4, 4), (0, 1, 12, 3)) + + for model, x, stride in ( + (ScaleByBuffer(), torch.randn(4), (1,)), + (AddStridedWeight(), torch.randn(1, 3, 4, 4), (48, 16, 4, 1)), + ): + exported = constant_prop_pass(export(model, (x,), strict=True)) + self.assertEqual( + [t.stride() for t in exported.constants.values()], [stride] + ) def test_emit_user_constants_with_zero_stride(self) -> None: def zero_stride(value): diff --git a/exir/lowered_backend_module.py b/exir/lowered_backend_module.py index ba8f0d6fdb8..68316fe3630 100644 --- a/exir/lowered_backend_module.py +++ b/exir/lowered_backend_module.py @@ -7,6 +7,7 @@ # pyre-strict import copy +import heapq import operator from collections import defaultdict from typing import Any, Dict, List, Optional, Set, Tuple, Union @@ -43,7 +44,6 @@ OutputSpec, TensorArgument, ) -from torch.fx.passes.tools_common import stable_topological_sort from torch.fx.passes.utils.fuser_utils import ( erase_nodes, fuse_as_graphmodule, @@ -866,6 +866,35 @@ def create_exported_program_from_submodule( ) +def _stable_topological_sort_in_place(graph: torch.fx.Graph) -> None: + """ + Orders the nodes like torch's stable_topological_sort, by moving them. That + function installs a new graph, and a GraphModule then recompiles itself and + every submodule, so sorting once per partition took time quadratic in the + number of partitions. + """ + position = {node: i for i, node in enumerate(graph.nodes)} + unsorted_inputs = {node: len(node.all_input_nodes) for node in graph.nodes} + ready = [(i, node) for node, i in position.items() if unsorted_inputs[node] == 0] + previous = None + while ready: + _, node = heapq.heappop(ready) + if previous is None: + first = next(iter(graph.nodes)) + if first is not node: + first.prepend(node) + elif previous.next is not node: + previous.append(node) + previous = node + del position[node] + for user in node.users: + unsorted_inputs[user] -= 1 + if unsorted_inputs[user] == 0: + heapq.heappush(ready, (position[user], user)) + if position: + raise AssertionError(f"Input graph has cycles, unable to add {list(position)}") + + def create_submodule_from_nodes( gm: torch.fx.GraphModule, node_list: NodeList, @@ -875,7 +904,8 @@ def create_submodule_from_nodes( """ Modifies the given graph module in-place to separate out the given nodes into a submodule. The given node_list should form a fully connected - subgraph. + subgraph. Unless skip_legalize_graph is set, the nodes of gm are sorted + in place. gm is not recompiled, so call gm.recompile() before running it. Args: gm: The graph module that we want to partition @@ -893,6 +923,7 @@ def create_submodule_from_nodes( ) _fixup_output_node(sub_gm) + sub_gm.recompile() gm = insert_subgm(gm, sub_gm, orig_inputs, orig_outputs) submodule_node = None @@ -934,7 +965,7 @@ def create_submodule_from_nodes( # TODO : T153794167 Get rid of support for skipping the sort in create_submodule_from_nodes # once we transition to using fuse_by_partitions. if not skip_legalize_graph: - stable_topological_sort(gm) + _stable_topological_sort_in_place(gm.graph) # Get the call_module node submodule_node = None diff --git a/exir/passes/constant_prop_pass.py b/exir/passes/constant_prop_pass.py index 769a9a9c2c1..aa8e4318954 100644 --- a/exir/passes/constant_prop_pass.py +++ b/exir/passes/constant_prop_pass.py @@ -20,7 +20,6 @@ _schema_paired_args, ) from executorch.exir.passes.replace_aten_with_edge_pass import aten_to_edge -from executorch.exir.tensor import without_zero_strides from torch._export.utils import ( get_buffer, get_lifted_tensor_constant, @@ -289,13 +288,22 @@ def get_propagated_const_tensor_dict( # Execute the `node.target` and create a new propagated constant tensor. prop_constant_tensor = node.target(*args_data, **kwargs_data) + # ExecuTorch doesn't support zero strides, so we need to ensure the tensor is contiguous + # if it has any zero strides from broadcasting/expansion operations. + # contiguous() is not enough: it keeps a zero stride on a size-1 dim. + if ( + isinstance(prop_constant_tensor, torch.Tensor) + and 0 in prop_constant_tensor.stride() + ): + prop_constant_tensor = prop_constant_tensor.clone( + memory_format=torch.contiguous_format + ) + # A folded constant must be a plain tensor that does not require # grad. no_grad is not enough when the op returns a view of a # parameter or the parameter itself. if isinstance(prop_constant_tensor, torch.Tensor): - prop_constant_tensor = without_zero_strides( - prop_constant_tensor - ).detach() + prop_constant_tensor = prop_constant_tensor.detach() const_node_to_tensor[node] = prop_constant_tensor # Only tensors can be lifted into placeholders. A non-tensor value read by @@ -458,11 +466,12 @@ def _update_output_node_and_specs( output_specs = exported_program.graph_signature.output_specs assert len(output_nodes) == len(output_specs) - clone_op = ( - exir_ops.edge.aten.clone.default - if _skip_dim_order - else exir_ops.edge.dim_order_ops._clone_dim_order.default - ) + if exported_program.dialect != "EDGE": + clone_op = torch.ops.aten.clone.default + elif _skip_dim_order: + clone_op = exir_ops.edge.aten.clone.default + else: + clone_op = exir_ops.edge.dim_order_ops._clone_dim_order.default for i in range(len(output_specs)): out_node = output_nodes[i] @@ -489,6 +498,8 @@ def constant_prop_pass( """ This pass is for constant propagation for Exported Program with lifted parameters, as the parameters will not be shown up as `get_attr` but as `placeholder` to the graph. + A graph output that is a constant gets a clone in front of it: an Edge clone when + exported_program.dialect is "EDGE", and torch.ops.aten.clone.default otherwise. Args: exported_program: The ExportedProgram to perform constant propagation on. diff --git a/exir/passes/propagate_device_pass.py b/exir/passes/propagate_device_pass.py index f0842ef788a..a3608b76de1 100644 --- a/exir/passes/propagate_device_pass.py +++ b/exir/passes/propagate_device_pass.py @@ -560,7 +560,8 @@ def _place_input_on_device( f"disable skip_h2d_for_method_inputs, or, for a mutable buffer, " f"have the partitioner put the buffer and every op that reads " f"or writes it in the delegate; tag_mutated_buffer tags the " - f"buffer once those ops are there." + f"buffer once those ops are there, unless the method also " + f"returns its new value or the new value is a method input." ) raise RuntimeError( f"{refusal} in place on the host. Either disable " diff --git a/exir/tests/test_delegate.py b/exir/tests/test_delegate.py index 915c832a591..4709c671762 100644 --- a/exir/tests/test_delegate.py +++ b/exir/tests/test_delegate.py @@ -274,6 +274,41 @@ def forward(self, x): torch.allclose(prog.exported_program().module()(*inputs), inputs[0] * 2) ) + def test_create_submodule_sorts_the_graph_in_place(self) -> None: + class Model(torch.nn.Module): + def forward(self, x, y): + a = x + y + b = a - y + c = a * y + return b / c + + inputs = (torch.randn(1, 3), torch.randn(1, 3)) + prog = to_edge(export(Model(), inputs, strict=True)) + gm = prog.exported_program().graph_module + graph = gm.graph + + node_list = [ + node + for node in graph.nodes + if node.op == "call_function" + and node.target + in {exir_ops.edge.aten.add.Tensor, exir_ops.edge.aten.mul.Tensor} + ] + # The fused call goes after mul, so sub reads the add result before it + # is defined until the graph is sorted. + sub = next( + node for node in graph.nodes if node.target == exir_ops.edge.aten.sub.Tensor + ) + sub_gm, _ = create_submodule_from_nodes(gm, node_list, "tag") + + self.assertIs(gm.graph, graph) + self.assertIn(sub, graph.nodes) + graph.lint() + self.assertIsInstance(sub_gm(*inputs), list) + self.assertTrue( + torch.allclose(prog.exported_program().module()(*inputs), Model()(*inputs)) + ) + def test_create_submodule_list_return(self) -> None: """ Original graph: diff --git a/exir/tests/test_passes.py b/exir/tests/test_passes.py index 81c5298aab1..ad626ec35e6 100644 --- a/exir/tests/test_passes.py +++ b/exir/tests/test_passes.py @@ -1637,6 +1637,35 @@ def forward(self) -> torch.Tensor: edge.exported_program()._validate() edge.to_executorch() + def test_constant_prop_for_output_clones_in_program_dialect(self) -> None: + class M(torch.nn.Module): + def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: + return x * 2, torch.arange(4, dtype=torch.float32) + 1 + + exported = export(M(), (torch.ones(4),), strict=True) + for ep in (exported.run_decompositions({}), exported): + with self.subTest(dialect=ep.dialect): + ep = constant_prop_pass(ep) + [_, folded] = ep.graph.output_node().args[0] + self.assertEqual(folded.target, torch.ops.aten.clone.default) + ep._validate() + edge = to_edge( + ep, compile_config=EdgeCompileConfig(_skip_dim_order=False) + ) + [_, folded] = edge.exported_program().graph.output_node().args[0] + self.assertEqual( + folded.target, exir_ops.edge.dim_order_ops._clone_dim_order.default + ) + + with self.subTest(dialect="EDGE"): + edge = to_edge( + export(M(), (torch.ones(4),), strict=True), + compile_config=EdgeCompileConfig(_skip_dim_order=True), + ) + ep = constant_prop_pass(edge.exported_program()) + [_, folded] = ep.graph.output_node().args[0] + self.assertEqual(folded.target, exir_ops.edge.aten.clone.default) + def test_constant_prop_preserves_memory_alloc(self) -> None: class Add(torch.nn.Module): def forward(self, x: torch.Tensor) -> torch.Tensor: From b1701a4bb32000043ef05566f66cbbacf03cc8ea Mon Sep 17 00:00:00 2001 From: pssrawat <34485295+pssrawat@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:25:54 -0500 Subject: [PATCH 039/128] Transpose lower-rank constants when removing permutes around them (#24315) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: `RemovePermutesAroundElementwiseOps` removes the permutes around a region of elementwise ops, so the region runs in its input's original layout. A constant feeding the region was written for the permuted layout, so it has to be converted with the region's end permutation (`node_end_perm`). Constants with the same rank as the region get a `permute_copy`, which is correct. This diff fixes constants with a lower rank, which broadcast into the region. The bug is in the shared pass, so every backend that uses it is affected: the Cortex-M explicit-layout pipeline, Arm (`RemovePermutesAroundElementwiseTosaOps` subclasses it without overriding this code) and Cadence. **Before** The constant's shape was padded with leading 1s, reordered by the permutation, trimmed back to the constant's rank, and applied with a single `view_copy`. A reshape keeps the elements in memory order and only changes the shape, so it is only equivalent to a transpose when no real data has to move. Trimming also dropped leading dims without checking that they were 1. Region `[1, 6, 4]`, `node_end_perm = [0, 2, 1]`: | Constant | `padded` | `target_shape` → trimmed | `moved` (not checked) | Op emitted | Result | |---|---|---|---|---|---| | `[6, 1]` (per-channel) | `[1, 6, 1]` | `[1, 1, 6]` → `[1, 6]` | `[1]` | `view_copy` → `[1, 6]` | correct | | `[4]` | `[1, 1, 4]` | `[1, 4, 1]` → `[1]` | `[2]` | `view_copy` → `[1]` | **crash**: `shape '[1]' is invalid for input of size 4` | | `[6, 4]` | `[1, 6, 4]` | `[1, 4, 6]` → `[4, 6]` | `[2, 1]` | `view_copy` → `[4, 6]` | **silently wrong output**: the data is reshaped, not transposed | **After** The constant is padded to the region's rank. The pass then checks whether the dims that hold data (size ≠ 1) keep their relative order under the permutation: | Step | Code | Meaning | |---|---|---| | 1 | `padded = [1] * (permute_rank - const_rank) + original_shape` | The constant's shape at the region's rank, as broadcasting sees it | | 2 | `moved = [d for d in node_end_perm if padded[d] != 1]` | The non-unit dims, in output order | | 3 | `moved == sorted(moved)` | Only unit dims move, so a reshape is exact: a single `view_copy` to the permuted shape (`permuted`), with leading 1s popped until it is back at the constant's rank or a non-unit dim is reached | | 4 | otherwise | Data has to move: a `view_copy` to `padded`, which only adds leading 1s, followed by a `permute_copy` by `node_end_perm` | Only leading 1s are dropped, so per-channel constants keep the same reshape as before and a non-unit dim is never cut off; broadcasting restores the dropped 1s. | Constant | `padded` | `permuted` | `moved` | Sorted? | Ops emitted | Result | |---|---|---|---|---|---|---| | `[6, 1]` (per-channel) | `[1, 6, 1]` | `[1, 1, 6]` → popped to `[1, 6]` | `[1]` | yes | `view_copy` → `[1, 6]` | correct, same graph as before | | `[4]` | `[1, 1, 4]` | `[1, 4, 1]` → popped to `[4, 1]` | `[2]` | yes | `view_copy` → `[4, 1]` | correct | | `[6, 4]` | `[1, 6, 4]` | not used | `[2, 1]` | no | `view_copy` → `[1, 6, 4]`, `permute_copy` → `[1, 4, 6]` | correct | Same-rank constants, single-element inputs and non-constant inputs are handled as before. Reviewed By: rascani Differential Revision: D128417497 Pull Request resolved: https://github.com/pytorch/executorch/pull/24315 --- .../remove_permutes_around_elementwise_ops.py | 27 +++++++--- .../test/test_permute_optimization_passes.py | 53 +++++++++++++++++++ 2 files changed, 72 insertions(+), 8 deletions(-) diff --git a/backends/transforms/remove_permutes_around_elementwise_ops.py b/backends/transforms/remove_permutes_around_elementwise_ops.py index 332abdd7101..b237b730882 100644 --- a/backends/transforms/remove_permutes_around_elementwise_ops.py +++ b/backends/transforms/remove_permutes_around_elementwise_ops.py @@ -802,7 +802,9 @@ def permute_subgraph(self, subgraph: Subgraph) -> bool: # noqa: C901 # fits its input. Same-rank broadcasting is safe: every operand carries # the region's permutation, and broadcasting commutes with applying one # permutation to all operands. numel-1 operands broadcast identically - # under any permutation, and cat never broadcasts. + # under any permutation, and cat never broadcasts. Constant operands + # are exempt: they are reshaped or permuted into the region's layout + # below. for node in subgraph.nodes: if ( node in interleave_nodes @@ -816,6 +818,7 @@ def permute_subgraph(self, subgraph: Subgraph) -> bool: # noqa: C901 self._concrete_shape(inp) for inp in node.all_input_nodes if isinstance(inp.meta.get("val"), torch.Tensor) + and (inp, node) not in subgraph.constant_edges_in ) if shape is not None and math.prod(shape) > 1 } @@ -892,13 +895,21 @@ def permute_subgraph(self, subgraph: Subgraph) -> bool: # noqa: C901 ): original_shape = list(const_node.meta["val"].shape) padded = [1] * (permute_rank - const_rank) + original_shape - target_shape = [padded[d] for d in node_end_perm] - target_shape = target_shape[permute_rank - const_rank :] - new_node = graph.create_node( - "call_function", - exir_ops.edge.aten.view_copy.default, - args=(const_node, target_shape), - ) + moved = [d for d in node_end_perm if padded[d] != 1] + view = exir_ops.edge.aten.view_copy.default + if moved == sorted(moved): + # Only unit dims move, so a reshape is exact. + permuted = [padded[d] for d in node_end_perm] + while len(permuted) > const_rank and permuted[0] == 1: + permuted.pop(0) + new_node = graph.call_function(view, (const_node, permuted)) + else: + new_node = graph.call_function(view, (const_node, padded)) + with graph.inserting_after(new_node): + new_node = graph.call_function( + exir_ops.edge.aten.permute_copy.default, + (new_node, node_end_perm), + ) else: continue user_node.replace_input_with(const_node, new_node) diff --git a/backends/transforms/test/test_permute_optimization_passes.py b/backends/transforms/test/test_permute_optimization_passes.py index d5f16609c32..5f9fae482f6 100644 --- a/backends/transforms/test/test_permute_optimization_passes.py +++ b/backends/transforms/test/test_permute_optimization_passes.py @@ -2680,6 +2680,59 @@ def test_permute_bypassed_by_one_region_is_kept_by_another(self) -> None: "permute_bypassed_by_one_region_is_kept_by_another", ) + def test_lower_rank_constant_is_transposed_into_region_layout(self) -> None: + # Constants that reorder non-unit dims need a transpose; constants that + # only move unit dims reshape. The channels-last cases use a start + # permutation that is not its own inverse, so applying the wrong one fails. + ncl_to_nlc = ((1, 4, 6), [0, 2, 1], [0, 2, 1]) + nchw_to_nhwc = ((1, 3, 4, 5), [0, 2, 3, 1], [0, 3, 1, 2]) + cases = ( + (ncl_to_nlc, (6, 4), 1), + (ncl_to_nlc, (4,), 0), + (ncl_to_nlc, (6, 1), 0), + (nchw_to_nhwc, (4, 5, 3), 1), + (nchw_to_nhwc, (5, 3), 1), + (nchw_to_nhwc, (3,), 0), + (nchw_to_nhwc, (4, 1, 1), 0), + ) + for (x_shape, start, end), const_shape, expected_permutes in cases: + with self.subTest(x_shape=x_shape, const_shape=const_shape): + builder = GraphBuilder() + x_data = torch.randn(*x_shape) + const_data = torch.randn(*const_shape) + x = builder.placeholder("x", x_data) + const = builder.placeholder("p_const", const_data) + p1 = builder.call_operator( + op=exir_ops.edge.aten.permute_copy.default, args=(x, start) + ) + add = builder.call_operator( + op=exir_ops.edge.aten.add.Tensor, args=(p1, const) + ) + p2 = builder.call_operator( + op=exir_ops.edge.aten.permute_copy.default, args=(add, end) + ) + builder.output([p2]) + original = builder.get_graph_module() + gm_before = copy.deepcopy(original) + + result = cast( + PassResult, RemovePermutesAroundElementwiseOps()(original) + ) + + self.assertTrue(result.modified) + validate_numerics( + gm_before, + result.graph_module, + [x_data, const_data], + f"lower_rank_constant_{const_shape}", + ) + self.assertEqual( + count_node( + result.graph_module, exir_ops.edge.aten.permute_copy.default + ), + expected_permutes, + ) + class LayoutPermuteVisibilityTest(unittest.TestCase): """The data-movement passes must see both permute dialects. From 03b4623a87dc0ae995961f1774630f926d63f6bb Mon Sep 17 00:00:00 2001 From: AxelNoun <150222552+AxelNoun@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:21:54 +0100 Subject: [PATCH 040/128] Raise InternalError for non-CPU constant storage in the emitter (#24202) ### Summary `_tensor_spec_to_evalue` reads a constant's bytes by casting `spec.storage.data_ptr()` to a host pointer. When the storage is on CUDA, that pointer is a device address, so `to_executorch()` segfaults (exit 139) with no Python exception. This PR checks the storage device first and raises an `InternalError`, as @shoumikhin suggested in #21545, that names the device and says how to fix the export. As discussed in the issue, lowering a CUDA module onto the portable path is not supported; only the way it fails changes. The check applies only to non-empty constants that the emitter serializes itself and whose storage is not on CPU, which are exactly the cases that reach the host read of a device pointer. Fixes #21545 ### Test plan New test `test_emit_non_cpu_constant_raises` in `exir/emit/test/test_emit.py`. It uses `meta` storage, which is not host-readable either and needs no accelerator. Without the guard, that case already fails with a wrapped "NULL pointer access" `InternalError`, so the test matches on the device name in the message. Built and installed from this branch after merging main at d6abe234 (`pip install . --no-build-isolation` on Ubuntu 24.04, Python 3.12, torch 2.14.0+cpu): ``` pytest exir/emit/test/test_emit.py ``` 70 passed, 2 skipped, as before the merge. With `_emitter.py` replaced by main's, the new test fails on the "NULL pointer access" message. `lintrunner --skip MYPY` reports no issues on the two files. I also ran the reproducer from #21545 on CUDA (RTX 3060 under WSL), with the same package and torch replaced by the cu130 build of the same version (2.14.0+cu130). With main's `_emitter.py` it segfaults in `_tensor_spec_to_evalue` (exit 139). With this branch it raises `InternalError: ... Constant tensor storage is on device 'cuda:0', ...` (exit 1). I used Claude Code to write this PR. cc @JacobSzwejbka @angelayi @nil-is-all @Gasoonjia @digantdesai --------- Co-authored-by: Claude Opus 5.5 --- exir/emit/_emitter.py | 15 +++++++++++++++ exir/emit/test/test_emit.py | 9 +++++++++ 2 files changed, 24 insertions(+) diff --git a/exir/emit/_emitter.py b/exir/emit/_emitter.py index 8bbeedd59f2..ad79ed1604b 100644 --- a/exir/emit/_emitter.py +++ b/exir/emit/_emitter.py @@ -471,6 +471,21 @@ def _tensor_spec_to_evalue( ctypes.c_char * typing.cast(torch.UntypedStorage, spec.storage).nbytes() ) + # data_ptr() is dereferenced from the host below; for non-CPU storage + # (e.g. CUDA) that read can segfault instead of raising (#21545). + storage_device = typing.cast(torch.UntypedStorage, spec.storage).device + if spec.allocated_memory != 0 and storage_device.type != "cpu": + raise InternalError( + self._emit_node_specific_error( + self.node, + f"Constant tensor storage is on device '{storage_device}', " + "but constants serialized into the program must be in CPU " + "memory. Move them to CPU before export (e.g. model.cpu()), " + "or delegate the ops that use them to a backend that " + "accepts device tensors.", + ) + ) + buffer_data = ( bytes( ctypes.cast( diff --git a/exir/emit/test/test_emit.py b/exir/emit/test/test_emit.py index cdc8d002696..7c0579c0c59 100644 --- a/exir/emit/test/test_emit.py +++ b/exir/emit/test/test_emit.py @@ -1853,6 +1853,15 @@ def forward(self, x): self.assertEqual(len(program.constant_buffer[1].storage), 4) self.assertEqual(len(program.constant_buffer[2].storage), 4) + def test_emit_non_cpu_constant_raises(self) -> None: + # Like CUDA storage, "meta" storage is not host-readable, but needs no + # accelerator. Unguarded, it already fails with a wrapped "NULL pointer + # access" InternalError, so match on the device name. + model = torch.nn.Linear(4, 3).to("meta") + edge = to_edge(export(model, (torch.ones(2, 4, device="meta"),), strict=True)) + with self.assertRaisesRegex(InternalError, "on device 'meta'"): + edge.to_executorch() + def test_non_persistent_buffer(self) -> None: class NonPersistentBuffer(nn.Module): def __init__(self): From 237c463554829b312f07cdb897101196a8fac5fe Mon Sep 17 00:00:00 2001 From: Digant Desai Date: Wed, 4 Nov 2026 00:42:14 -0600 Subject: [PATCH 041/128] Pin Hugging Face revisions in test_torchao_huggingface_checkpoints.sh (#24524) On OSDC runners HF_HUB_CACHE is a read-only mount seeded with specific snapshots, so an unpinned `hf download` fails with "Read-only file system" whenever the upstream repo gets a new commit. LiquidAI/LFM2.5-1.2B-Instruct picked up a README-only commit (51d09154) on 2026-11-03, breaking the lfm2_5_1_2b trunk job. Pin all three models to their current cached revisions; bumping a revision now requires the ci-refresh-hf-cache label. --- .../test_torchao_huggingface_checkpoints.sh | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/.ci/scripts/test_torchao_huggingface_checkpoints.sh b/.ci/scripts/test_torchao_huggingface_checkpoints.sh index f80eb9e9f44..b1864cf2562 100644 --- a/.ci/scripts/test_torchao_huggingface_checkpoints.sh +++ b/.ci/scripts/test_torchao_huggingface_checkpoints.sh @@ -47,6 +47,16 @@ fi MODEL_OUT=model.pte +# Hugging Face Hub revisions, pinned. On OSDC runners HF_HUB_CACHE is a read-only +# mount seeded with these snapshots, so downloading an unpinned repo fails with +# "Read-only file system" as soon as the repo gets a new commit, even a README +# edit (LiquidAI/LFM2.5-1.2B-Instruct on 2026-11-03). To move to a newer +# revision, update it here and add the ci-refresh-hf-cache label to the PR so +# the cache gets seeded. +QWEN3_4B_REVISION=3d10de24598fff7a1ff7297103693fe1d2b82a5e +PHI_4_MINI_REVISION=ca214ad7becf450bed20235bc677418a092a718a +LFM2_5_1_2B_REVISION=0f604ada3f766f9f257460c4c9f0b5d6f69d431b + # Default to XNNPACK BACKEND_ARGS="-X --xnnpack-extended-ops" @@ -57,7 +67,7 @@ fi case "$MODEL_NAME" in qwen3_4b) echo "Running Qwen3-4B export..." - HF_MODEL_DIR=$(hf download pytorch/Qwen3-4B-INT8-INT4) + HF_MODEL_DIR=$(hf download pytorch/Qwen3-4B-INT8-INT4 --revision "${QWEN3_4B_REVISION}") EXPECTED_MODEL_SIZE_UPPER_BOUND=$((3 * 1024 * 1024 * 1024)) # 3GB $PYTHON_EXECUTABLE -m executorch.examples.models.qwen3.convert_weights \ $HF_MODEL_DIR \ @@ -80,7 +90,7 @@ case "$MODEL_NAME" in phi_4_mini) echo "Running Phi-4-mini export..." - HF_MODEL_DIR=$(hf download pytorch/Phi-4-mini-instruct-INT8-INT4) + HF_MODEL_DIR=$(hf download pytorch/Phi-4-mini-instruct-INT8-INT4 --revision "${PHI_4_MINI_REVISION}") EXPECTED_MODEL_SIZE_UPPER_BOUND=$((3 * 1024 * 1024 * 1024)) # 3GB $PYTHON_EXECUTABLE -m executorch.examples.models.phi_4_mini.convert_weights \ $HF_MODEL_DIR \ @@ -103,7 +113,7 @@ case "$MODEL_NAME" in lfm2_5_1_2b) echo "Running LFM2.5-1.2B export..." - HF_MODEL_DIR=$(hf download LiquidAI/LFM2.5-1.2B-Instruct) + HF_MODEL_DIR=$(hf download LiquidAI/LFM2.5-1.2B-Instruct --revision "${LFM2_5_1_2B_REVISION}") EXPECTED_MODEL_SIZE_UPPER_BOUND=$((2500 * 1024 * 1024)) # 2.5GB $PYTHON_EXECUTABLE -m executorch.examples.models.lfm2.convert_weights \ $HF_MODEL_DIR \ From 4dd3ef23e0c2be915ba254be7575243982788f89 Mon Sep 17 00:00:00 2001 From: Erik Lundell Date: Wed, 4 Nov 2026 09:13:19 +0100 Subject: [PATCH 042/128] Arm backend: Write PMU metrics to etdump if available (#24507) This is done as soon as ethosu_execution_stats is linked, was built with ET_EVENT_TRACER_ENABLED, and a tracer is available. No EthosUStatsBuffer registration is neccessary. The metrics are written as a profile_event with delegate_debug_id_str = "ethosu.pmu.counters", and delegate_debug_metadata binary blob described in EthosUExecutionStats.h. The blob begins with `EUC1` (EthosU Counters), where 1 is a versioning number. For consuming the metrics, backends/arm/ethosu/parse_delegate_metadata.py is added that provides parse_pmu_profile_event() -> EthosUPmuProfileEvent, that parsers the blob into a typed dataclass, and parse_ethosu_delegate_pmu_metadata() -> dict that can be passed to the delegate_metadata_parser argument of Inspector. Signed-off-by: Erik Lundell --- backends/arm/BUCK | 1 + backends/arm/CMakeLists.txt | 4 +- .../arm/ethosu/parse_delegate_metadata.py | 441 ++++++++++++++++++ .../arm/runtime/EthosUBackend_CoreDriver.cpp | 10 +- backends/arm/runtime/EthosUBackend_Pmu.h | 8 + backends/arm/runtime/EthosUExecutionStats.cpp | 67 ++- backends/arm/runtime/EthosUExecutionStats.h | 14 + backends/arm/runtime/targets.bzl | 1 + .../arm/runtime/tests/ethos-u/CMakeLists.txt | 27 +- .../arm/test/ethosu_pmu_callbacks_test.cpp | 155 ++++++ .../test_parse_ethosu_delegate_metadata.py | 150 ++++++ backends/arm/test/targets.bzl | 6 + .../generate_sampled.py | 4 +- 13 files changed, 880 insertions(+), 8 deletions(-) create mode 100644 backends/arm/ethosu/parse_delegate_metadata.py create mode 100644 backends/arm/test/misc/test_parse_ethosu_delegate_metadata.py diff --git a/backends/arm/BUCK b/backends/arm/BUCK index 95092effd7c..4efbdf6c69b 100644 --- a/backends/arm/BUCK +++ b/backends/arm/BUCK @@ -84,6 +84,7 @@ fbcode_target( "ethosu/__init__.py", "ethosu/backend.py", "ethosu/compile_spec.py", + "ethosu/parse_delegate_metadata.py", "ethosu/partitioner.py", ], deps = [ diff --git a/backends/arm/CMakeLists.txt b/backends/arm/CMakeLists.txt index 69ec45ccd17..15371937dd4 100644 --- a/backends/arm/CMakeLists.txt +++ b/backends/arm/CMakeLists.txt @@ -100,7 +100,9 @@ if(EXECUTORCH_BUILD_ARM_BAREMETAL OR EXECUTORCH_BUILD_ARM_ETHOSU_LINUX) target_include_directories( ethosu_execution_stats PRIVATE ${DRIVER_ETHOSU_INCLUDE_DIR} ) - target_link_libraries(ethosu_execution_stats PUBLIC ethosu_core_driver) + target_link_libraries( + ethosu_execution_stats PUBLIC ethosu_core_driver executorch_core + ) elseif(EXECUTORCH_BUILD_ARM_ETHOSU_LINUX) target_sources( executorch_delegate_ethos_u diff --git a/backends/arm/ethosu/parse_delegate_metadata.py b/backends/arm/ethosu/parse_delegate_metadata.py new file mode 100644 index 00000000000..100e6981031 --- /dev/null +++ b/backends/arm/ethosu/parse_delegate_metadata.py @@ -0,0 +1,441 @@ +# Copyright 2026 Arm Limited and/or its affiliates. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. +"""Decode experimental Ethos-U PMU metadata from ETDump profile events. + +The wire format and public Python types may change without compatibility +support. + +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import IntEnum +from struct import Struct +from typing import Any, Sequence + +_HEADER = Struct("<4sIIIIQQI") +_COUNTER = Struct(" EthosUPmuProfileEvent: + """Decode one Ethos-U PMU delegate metadata blob (experimental API). + + Args: + metadata (bytes): Raw ETDump delegate debug metadata. + + Returns: + EthosUPmuProfileEvent: Decoded NPU cycles and PMU counters. + + Raises: + ValueError: If the blob has an invalid format, version, or event ID. + + """ + if len(metadata) < 4: + raise ValueError("Ethos-U PMU metadata is shorter than its header") + if metadata[:3] != _MAGIC_PREFIX: + raise ValueError(f"Unsupported Ethos-U PMU metadata magic: {metadata[:4]!r}") + try: + version = int(metadata[3:4]) + except ValueError as exc: + raise ValueError("Invalid Ethos-U PMU metadata version") from exc + parsers = {1: _parse_pmu_profile_event_v1} + parser = parsers.get(version) + if parser is None: + raise ValueError( + f"Unsupported Ethos-U PMU metadata version: {version}; " + f"supported versions: {set(parsers.keys())}" + ) + else: + return parser(metadata) + + +def _parse_pmu_profile_event_v1(metadata: bytes) -> EthosUPmuProfileEvent: + if len(metadata) < _HEADER.size: + raise ValueError("Ethos-U PMU metadata is shorter than its header") + + ( + _, + product, + macs_per_cycle, + platform_id, + flags, + register_base, + cycle_count, + counter_count, + ) = _HEADER.unpack_from(metadata) + try: + platform = EthosUPlatform(platform_id) + except ValueError as exc: + raise ValueError(f"Unknown Ethos-U platform ID: {platform_id}") from exc + try: + product_type = EthosUProduct(product) + except ValueError as exc: + raise ValueError(f"Unknown Ethos-U product ID: {product}") from exc + if flags & ~0b11: + raise ValueError(f"Unsupported Ethos-U PMU flags: {flags:#x}") + if counter_count > _MAX_COUNTERS: + raise ValueError(f"Too many Ethos-U PMU counters: {counter_count}") + + expected_size = _HEADER.size + counter_count * _COUNTER.size + if len(metadata) != expected_size: + raise ValueError( + f"Ethos-U PMU metadata has {len(metadata)} bytes; expected {expected_size}" + ) + + counters = _parse_counters(metadata, _HEADER.size, counter_count) + + return EthosUPmuProfileEvent( + product=product_type, + platform=platform, + macs_per_cycle=macs_per_cycle, + success=bool(flags & 2), + register_base=register_base, + cycle_count=float("nan") if flags & 1 else float(cycle_count), + cycle_overflow=bool(flags & 1), + counters=counters, + ) + + +def _parse_counters( + metadata: bytes, offset: int, counter_count: int +) -> tuple[EthosUPmuCounter, ...]: + counters = [] + for slot in range(counter_count): + event_id, count, overflow = _COUNTER.unpack_from( + metadata, offset + slot * _COUNTER.size + ) + try: + event_type = EthosUPmuEvent(event_id) + except ValueError as exc: + raise ValueError(f"Unknown Ethos-U PMU event ID: {event_id}") from exc + if overflow not in (0, 1): + raise ValueError(f"Invalid Ethos-U PMU overflow value: {overflow}") + counters.append( + EthosUPmuCounter( + event_type, float("nan") if overflow else float(count), bool(overflow) + ) + ) + return tuple(counters) + + +def parse_ethosu_delegate_pmu_metadata( + metadata_list: Sequence[bytes | bytearray | str | None], +) -> dict[str, Any]: + """Decode Ethos-U PMU samples for Inspector (experimental API). + + Args: + metadata_list (Sequence[bytes | bytearray | str | None]): Raw samples. + + Returns: + dict[str, Any]: Decoded samples in the `ethosu_pmu` key, or an empty + dictionary for unrelated delegate metadata. Missing samples retain + their positions as None. + + """ + records: list[dict[str, Any] | None] = [] + found = False + for metadata in metadata_list: + if not isinstance(metadata, (bytes, bytearray)) or not metadata.startswith( + _MAGIC_PREFIX + ): + records.append(None) + continue + + record = parse_pmu_profile_event(bytes(metadata)) + found = True + records.append( + { + "product": record.product, + "platform": record.platform, + "macs_per_cycle": record.macs_per_cycle, + "success": record.success, + "register_base": record.register_base, + "cycle_count": record.cycle_count, + "cycle_overflow": record.cycle_overflow, + "counters": [ + { + "event_type": counter.event_type, + "event_id": counter.event_type.value, + "count": counter.count, + "overflow": counter.overflow, + } + for counter in record.counters + ], + } + ) + + return {"ethosu_pmu": records} if found else {} diff --git a/backends/arm/runtime/EthosUBackend_CoreDriver.cpp b/backends/arm/runtime/EthosUBackend_CoreDriver.cpp index 5d905365329..d5e67b55b5f 100644 --- a/backends/arm/runtime/EthosUBackend_CoreDriver.cpp +++ b/backends/arm/runtime/EthosUBackend_CoreDriver.cpp @@ -54,6 +54,7 @@ namespace arm { struct PlatformState {}; +// cppcheck-suppress unusedFunction executorch::runtime::Error platform_init( executorch::runtime::ArrayRef /*specs*/, executorch::runtime::MemoryAllocator* /*allocator*/, @@ -61,14 +62,18 @@ executorch::runtime::Error platform_init( return executorch::runtime::Error::Ok; } +// cppcheck-suppress unusedFunction void platform_destroy(PlatformState* /*state*/) {} +// cppcheck-suppress unusedFunction bool needs_scratch_allocation() { return true; } +// cppcheck-suppress unusedFunction Error platform_execute( - BackendExecutionContext& /*context*/, + // cppcheck-suppress constParameterReference + [[maybe_unused]] BackendExecutionContext& context, [[maybe_unused]] const ExecutionHandle* execution_handle, const VelaHandles& handles, int input_count, @@ -137,6 +142,9 @@ Error platform_execute( executorch::backends::arm::EthosUInvocationContext invocation_context{}; invocation_context.delegate_handle = reinterpret_cast(execution_handle); +#if defined(ET_EVENT_TRACER_ENABLED) + invocation_context.event_tracer = context.event_tracer(); +#endif int result = ethosu_invoke_v3( driver.get(), static_cast(handles.cmd_data), diff --git a/backends/arm/runtime/EthosUBackend_Pmu.h b/backends/arm/runtime/EthosUBackend_Pmu.h index b80098235c6..1b026a45faa 100644 --- a/backends/arm/runtime/EthosUBackend_Pmu.h +++ b/backends/arm/runtime/EthosUBackend_Pmu.h @@ -8,6 +8,10 @@ #include +#if defined(ET_EVENT_TRACER_ENABLED) +#include +#endif + namespace executorch::backends::arm { // EthosUStatsBuffer is defined in EthosUExecutionStats.h. @@ -17,6 +21,10 @@ struct EthosUInvocationContext { EthosUStatsBuffer* buffer = nullptr; uintptr_t delegate_handle = 0; uint32_t counter_count = 0; +#if defined(ET_EVENT_TRACER_ENABLED) + executorch::runtime::EventTracer* event_tracer = nullptr; + executorch::runtime::EventTracerEntry cycle_event{}; +#endif }; bool ethosu_execution_metrics_enabled() noexcept; diff --git a/backends/arm/runtime/EthosUExecutionStats.cpp b/backends/arm/runtime/EthosUExecutionStats.cpp index 92225bea937..7c51d1762eb 100644 --- a/backends/arm/runtime/EthosUExecutionStats.cpp +++ b/backends/arm/runtime/EthosUExecutionStats.cpp @@ -13,6 +13,22 @@ #include #include +#if defined(ET_EVENT_TRACER_ENABLED) +namespace { +void write_le32(uint8_t* destination, uint32_t value) { + for (size_t byte = 0; byte < 4; ++byte) { + destination[byte] = static_cast(value >> (8 * byte)); + } +} + +void write_le64(uint8_t* destination, uint64_t value) { + for (size_t byte = 0; byte < 8; ++byte) { + destination[byte] = static_cast(value >> (8 * byte)); + } +} +} // namespace +#endif + namespace executorch::backends::arm { namespace { std::atomic capture_buffer{nullptr}; @@ -44,7 +60,11 @@ void ethosu_inference_begin(struct ethosu_driver* drv, void* user_arg) { invocation->counter_count = 0; invocation->buffer = executorch::backends::arm::capture_buffer.load(std::memory_order_acquire); - if (invocation->buffer != nullptr) { + if (invocation->buffer != nullptr +#if defined(ET_EVENT_TRACER_ENABLED) + || invocation->event_tracer != nullptr +#endif + ) { static constexpr ethosu_pmu_event_type kU55U65Events[] = { ETHOSU_PMU_AXI0_RD_DATA_BEAT_RECEIVED, ETHOSU_PMU_AXI1_RD_DATA_BEAT_RECEIVED, @@ -88,6 +108,14 @@ void ethosu_inference_begin(struct ethosu_driver* drv, void* user_arg) { ETHOSU_PMU_CYCCNT_Reset(drv); ETHOSU_PMU_EVCNTR_ALL_Reset(drv); ETHOSU_PMU_Set_CNTR_OVS(drv, event_mask | ETHOSU_PMU_CCNT_Msk); +#if defined(ET_EVENT_TRACER_ENABLED) + if (invocation->event_tracer != nullptr) { + invocation->cycle_event = + invocation->event_tracer->start_profiling_delegate( + executorch::backends::arm::kEthosUCycleEventName, + executorch::runtime::kUnsetDelegateDebugIntId); + } +#endif } } @@ -101,7 +129,7 @@ void ethosu_inference_end(struct ethosu_driver* drv, void* user_arg) { return; } - if (invocation->buffer != nullptr && invocation->counter_count != 0) { + if (invocation->counter_count != 0) { executorch::backends::arm::EthosUExecutionRecord record{}; record.delegate_handle = invocation->delegate_handle; record.success = drv->job.result == ETHOSU_JOB_RESULT_OK; @@ -120,7 +148,40 @@ void ethosu_inference_end(struct ethosu_driver* drv, void* user_arg) { } ETHOSU_PMU_Disable(drv); EthosUBackend_npu_end(); - invocation->buffer->append(record); +#if defined(ET_EVENT_TRACER_ENABLED) + if (invocation->event_tracer != nullptr) { + // Encode metadata in format described in EthosUExecutionStats.h + uint8_t metadata[executorch::backends::arm::kEthosUCycleMetadataMaxSize]{ + 'E', 'U', 'C', '1'}; + write_le32(metadata + 4, record.stats.product); + write_le32(metadata + 8, 1u << drv->dev.caps.log2_macs); + write_le32(metadata + 12, static_cast(record.stats.platform)); + const uint32_t flags = + (record.stats.cycle_overflow ? 1u : 0u) | (record.success ? 2u : 0u); + write_le32(metadata + 16, flags); + write_le64(metadata + 20, record.stats.device_identifier); + write_le64(metadata + 28, record.stats.cycle_count); + write_le32(metadata + 36, record.stats.counter_count); + for (uint32_t slot = 0; slot < record.stats.counter_count; ++slot) { + const size_t offset = + executorch::backends::arm::kEthosUCycleMetadataHeaderSize + + slot * executorch::backends::arm::kEthosUCycleCounterMetadataSize; + const auto& counter = record.stats.counters[slot]; + write_le32(metadata + offset, counter.event_id); + write_le64(metadata + offset + 4, counter.value); + write_le32(metadata + offset + 12, counter.overflow ? 1u : 0u); + } + const size_t metadata_size = + executorch::backends::arm::kEthosUCycleMetadataHeaderSize + + record.stats.counter_count * + executorch::backends::arm::kEthosUCycleCounterMetadataSize; + invocation->event_tracer->end_profiling_delegate( + invocation->cycle_event, metadata, metadata_size); + } +#endif + if (invocation->buffer != nullptr) { + invocation->buffer->append(record); + } return; } EthosUBackend_npu_end(); diff --git a/backends/arm/runtime/EthosUExecutionStats.h b/backends/arm/runtime/EthosUExecutionStats.h index fa04759f237..4e3c905255c 100644 --- a/backends/arm/runtime/EthosUExecutionStats.h +++ b/backends/arm/runtime/EthosUExecutionStats.h @@ -36,6 +36,20 @@ bool ethosu_execution_metrics_enabled() noexcept; constexpr size_t kMaxPmuEventCounters = 8; +constexpr char kEthosUCycleEventName[] = "ethosu.pmu.counters"; +// Experimental format; layout and fields may change without compatibility. +// ETDump delegate_debug_metadata starts with "EUC" and ASCII version '1' +// [0:4], followed by little-endian product [4:8], MACs per cycle [8:12], +// EthosUPlatform [12:16], flags [16:20] (bit 0: cycle overflow, +// bit 1: invocation succeeded), register base [20:28], NPU cycle count +// [28:36], and counter count [36:40]. +// Each populated counter then stores +// a symbolic event ID (4 bytes), count (8 bytes), and overflow (4 bytes). +constexpr size_t kEthosUCycleMetadataHeaderSize = 40; +constexpr size_t kEthosUCycleCounterMetadataSize = 16; +constexpr size_t kEthosUCycleMetadataMaxSize = kEthosUCycleMetadataHeaderSize + + kMaxPmuEventCounters * kEthosUCycleCounterMetadataSize; + enum class EthosUPlatform : uint8_t { CoreDriver, }; diff --git a/backends/arm/runtime/targets.bzl b/backends/arm/runtime/targets.bzl index 8ddabe90ade..75cb47c7bbc 100644 --- a/backends/arm/runtime/targets.bzl +++ b/backends/arm/runtime/targets.bzl @@ -25,6 +25,7 @@ def define_common_targets(): runtime.cxx_library( name = "ethosu_invocation_context", exported_headers = ["EthosUBackend_Pmu.h"], + exported_deps = ["//executorch/runtime/core:event_tracer"], visibility = ["PUBLIC"], ) runtime.cxx_library( diff --git a/backends/arm/runtime/tests/ethos-u/CMakeLists.txt b/backends/arm/runtime/tests/ethos-u/CMakeLists.txt index 5a2ee4240e9..dbd860a03be 100644 --- a/backends/arm/runtime/tests/ethos-u/CMakeLists.txt +++ b/backends/arm/runtime/tests/ethos-u/CMakeLists.txt @@ -92,6 +92,26 @@ target_include_directories( "${EXECUTORCH_ROOT}/.." "${EXECUTORCH_ROOT}/examples/arm/arm-scratch/ethos-u/core_software/core_driver/include" ) +target_compile_definitions( + ethosu_execution_stats_test_library PRIVATE ET_EVENT_TRACER_ENABLED +) +target_link_libraries( + ethosu_execution_stats_test_library PUBLIC executorch_core +) + +add_library( + ethosu_execution_stats_no_trace_test_library STATIC + "${EXECUTORCH_ROOT}/backends/arm/runtime/EthosUExecutionStats.cpp" +) +target_include_directories( + ethosu_execution_stats_no_trace_test_library + PRIVATE + "${EXECUTORCH_ROOT}/.." + "${EXECUTORCH_ROOT}/examples/arm/arm-scratch/ethos-u/core_software/core_driver/include" +) +target_link_libraries( + ethosu_execution_stats_no_trace_test_library PUBLIC executorch_core +) add_executable( ethosu_execution_stats_test @@ -105,8 +125,8 @@ target_include_directories( "${EXECUTORCH_ROOT}/examples/arm/arm-scratch/ethos-u/core_software/core_driver/include" ) target_link_libraries( - ethosu_execution_stats_test PRIVATE ethosu_execution_stats_test_library - Threads::Threads + ethosu_execution_stats_test + PRIVATE ethosu_execution_stats_no_trace_test_library Threads::Threads ) add_test(NAME ethosu_execution_stats COMMAND ethosu_execution_stats_test) set_tests_properties( @@ -124,6 +144,9 @@ target_include_directories( "${EXECUTORCH_ROOT}/.." "${EXECUTORCH_ROOT}/examples/arm/arm-scratch/ethos-u/core_software/core_driver/include" ) +target_compile_definitions( + ethosu_pmu_callbacks_test PRIVATE ET_EVENT_TRACER_ENABLED +) target_link_libraries( ethosu_pmu_callbacks_test PRIVATE ethosu_execution_stats_test_library Threads::Threads diff --git a/backends/arm/test/ethosu_pmu_callbacks_test.cpp b/backends/arm/test/ethosu_pmu_callbacks_test.cpp index f984be616c7..8914f60ef4b 100644 --- a/backends/arm/test/ethosu_pmu_callbacks_test.cpp +++ b/backends/arm/test/ethosu_pmu_callbacks_test.cpp @@ -15,6 +15,7 @@ #include #include +#include #include #define CHECK(expr) \ @@ -26,6 +27,96 @@ } while (false) using namespace executorch::backends::arm; +using namespace executorch::runtime; + +class CapturingTracer final : public EventTracer { + public: + uint8_t metadata[kEthosUCycleMetadataMaxSize]{}; + size_t metadata_size = 0; + bool started = false; + bool ended = false; + + void create_event_block(const char*) override {} + EventTracerEntry start_profiling(const char*, ChainID, DebugHandle) override { + return {}; + } + EventTracerEntry start_profiling_delegate( + const char* name, + DelegateDebugIntId index) override { + CHECK(std::strcmp(name, kEthosUCycleEventName) == 0); + CHECK(index == kUnsetDelegateDebugIntId); + started = true; + EventTracerEntry entry{}; + entry.event_id = 17; + return entry; + } + void end_profiling_delegate( + EventTracerEntry entry, + const void* data, + size_t size) override { + CHECK(started); + CHECK(entry.event_id == 17); + CHECK(size <= sizeof(metadata)); + std::memcpy(metadata, data, size); + metadata_size = size; + ended = true; + } + void log_profiling_delegate( + const char*, + DelegateDebugIntId, + et_timestamp_t, + et_timestamp_t, + const void*, + size_t) override {} + void end_profiling(EventTracerEntry) override {} + void track_allocation(AllocatorID, size_t) override {} + AllocatorID track_allocator(const char*) override { + return 0; + } + Result log_evalue(const EValue&, LoggedEValueType) override { + return true; + } + Result log_intermediate_output_delegate( + const char*, + DelegateDebugIntId, + const executorch::aten::Tensor&) override { + return true; + } + Result log_intermediate_output_delegate( + const char*, + DelegateDebugIntId, + const ArrayRef) override { + return true; + } + Result log_intermediate_output_delegate( + const char*, + DelegateDebugIntId, + const int&) override { + return true; + } + Result log_intermediate_output_delegate( + const char*, + DelegateDebugIntId, + const bool&) override { + return true; + } + Result log_intermediate_output_delegate( + const char*, + DelegateDebugIntId, + const double&) override { + return true; + } + void set_delegation_intermediate_output_filter( + EventTracerFilterBase*) override {} +}; + +uint64_t read_le(const uint8_t* bytes, size_t size) { + uint64_t value = 0; + for (size_t index = 0; index < size; ++index) { + value |= static_cast(bytes[index]) << (8 * index); + } + return value; +} extern "C" { void ethosu_inference_begin(ethosu_driver*, void*); @@ -112,5 +203,69 @@ int main() { CHECK(observer_ends == 3); CHECK(set_ethosu_stats_buffer(nullptr) == &buffer); + + CapturingTracer tracer; + EthosUInvocationContext traced_invocation{}; + traced_invocation.event_tracer = &tracer; + ethosu_inference_begin(&u85, &traced_invocation); + CHECK(pmu(&u85).enabled); + pmu(&u85).cycles = 123456; + pmu(&u85).counts[0] = 11; + pmu(&u85).counts[5] = 67; + pmu(&u85).overflow = ETHOSU_PMU_CCNT_Msk | (1u << 5); + ethosu_inference_end(&u85, &traced_invocation); + CHECK(!pmu(&u85).enabled); + CHECK(tracer.started && tracer.ended); + CHECK( + tracer.metadata_size == + kEthosUCycleMetadataHeaderSize + 7 * kEthosUCycleCounterMetadataSize); + CHECK(std::memcmp(tracer.metadata, "EUC1", 4) == 0); + CHECK(read_le(tracer.metadata + 4, 4) == 2); + CHECK(read_le(tracer.metadata + 8, 4) == 256); + CHECK( + read_le(tracer.metadata + 12, 4) == + static_cast(EthosUPlatform::CoreDriver)); + CHECK(read_le(tracer.metadata + 16, 4) == 1); + CHECK(read_le(tracer.metadata + 20, 8) == 0x50000000); + CHECK(read_le(tracer.metadata + 28, 8) == 123456); + CHECK(read_le(tracer.metadata + 36, 4) == 7); + for (size_t slot = 0; slot < 7; ++slot) { + const size_t offset = + kEthosUCycleMetadataHeaderSize + slot * kEthosUCycleCounterMetadataSize; + CHECK(read_le(tracer.metadata + offset, 4) == pmu(&u85).events[slot]); + CHECK(read_le(tracer.metadata + offset + 4, 8) == pmu(&u85).counts[slot]); + CHECK(read_le(tracer.metadata + offset + 12, 4) == (slot == 5)); + } + + CapturingTracer other_tracer; + EthosUInvocationContext other_invocation{}; + other_invocation.event_tracer = &other_tracer; + ethosu_inference_begin(&u55, &other_invocation); + pmu(&u55).cycles = 45; + pmu(&u55).counts[3] = 9; + pmu(&u55).overflow = 1u << 3; + ethosu_inference_end(&u55, &other_invocation); + CHECK(other_tracer.started && other_tracer.ended); + CHECK( + other_tracer.metadata_size == + kEthosUCycleMetadataHeaderSize + 4 * kEthosUCycleCounterMetadataSize); + CHECK(read_le(other_tracer.metadata + 8, 4) == 128); + CHECK( + read_le(other_tracer.metadata + 12, 4) == + static_cast(EthosUPlatform::CoreDriver)); + CHECK(read_le(other_tracer.metadata + 16, 4) == 2); + CHECK(read_le(other_tracer.metadata + 20, 8) == 0x40000000); + CHECK(read_le(other_tracer.metadata + 28, 8) == 45); + CHECK(read_le(other_tracer.metadata + 36, 4) == 4); + for (size_t slot = 0; slot < 4; ++slot) { + const size_t offset = + kEthosUCycleMetadataHeaderSize + slot * kEthosUCycleCounterMetadataSize; + CHECK(read_le(other_tracer.metadata + offset, 4) == pmu(&u55).events[slot]); + CHECK( + read_le(other_tracer.metadata + offset + 4, 8) == + pmu(&u55).counts[slot]); + CHECK(read_le(other_tracer.metadata + offset + 12, 4) == (slot == 3)); + } + CHECK(buffer.size == 2); std::puts("Ethos-U PMU callbacks: PASS"); } diff --git a/backends/arm/test/misc/test_parse_ethosu_delegate_metadata.py b/backends/arm/test/misc/test_parse_ethosu_delegate_metadata.py new file mode 100644 index 00000000000..bf03f80d5db --- /dev/null +++ b/backends/arm/test/misc/test_parse_ethosu_delegate_metadata.py @@ -0,0 +1,150 @@ +# Copyright 2026 Arm Limited and/or its affiliates. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import math +import struct +import unittest + +from executorch.backends.arm.ethosu.parse_delegate_metadata import ( + _COUNTER, + _HEADER, + _MAGIC_PREFIX, + EthosUPlatform, + EthosUPmuEvent, + EthosUProduct, + parse_ethosu_delegate_pmu_metadata, + parse_pmu_profile_event, +) + + +def _metadata( + *counters: tuple[int, int, int], + flags: int = 2, + product: int = 2, + platform: int = 0, + macs_per_cycle: int = 256, +) -> bytes: + header = _HEADER.pack( + _MAGIC_PREFIX + b"1", + product, + macs_per_cycle, + platform, + flags, + 0x50000000, + 123456, + len(counters), + ) + return header + b"".join(_COUNTER.pack(*counter) for counter in counters) + + +class TestEthosUDelegateMetadata(unittest.TestCase): + """Check Ethos-U PMU metadata decoding and Inspector adaptation.""" + + def test_decodes_product(self) -> None: + """Map each core-driver product ID to its enum value.""" + for product in EthosUProduct: + with self.subTest(product=product): + self.assertIs( + parse_pmu_profile_event(_metadata(product=product.value)).product, + product, + ) + + def test_decodes_profile_event(self) -> None: + """Decode counters into their symbolic event types.""" + metadata = _metadata((97, 67, 1), (178, 11, 0)) + + record = parse_pmu_profile_event(metadata) + + self.assertIs(record.product, EthosUProduct.U85) + self.assertIs(record.platform, EthosUPlatform.CoreDriver) + self.assertEqual(record.macs_per_cycle, 256) + self.assertTrue(record.success) + self.assertEqual(record.register_base, 0x50000000) + self.assertEqual(record.cycle_count, 123456) + self.assertIsInstance(record.cycle_count, float) + self.assertFalse(record.cycle_overflow) + self.assertEqual(len(record.counters), 2) + self.assertEqual( + record.counters[0].event_type, EthosUPmuEvent.ETHOSU_PMU_MAC_ACTIVE + ) + self.assertTrue(math.isnan(record.counters[0].count)) + self.assertTrue(record.counters[0].overflow) + self.assertEqual( + record.counters[1].event_type, EthosUPmuEvent.ETHOSU_PMU_WD_ACTIVE + ) + self.assertFalse(record.counters[1].overflow) + self.assertEqual(record.counters[1].count, 11) + self.assertIsInstance(record.counters[1].count, float) + + def test_cycle_overflow_returns_nan(self) -> None: + """Use NaN when the cycle counter overflows.""" + record = parse_pmu_profile_event(_metadata(flags=3)) + + self.assertTrue(record.cycle_overflow) + self.assertTrue(math.isnan(record.cycle_count)) + + def test_inspector_adapter_preserves_sample_positions(self) -> None: + """Keep sample indices when unrelated or empty entries occur.""" + metadata = _metadata((112, 45, 0)) + + parsed = parse_ethosu_delegate_pmu_metadata([b"", b"other", metadata]) + + self.assertEqual(parsed["ethosu_pmu"][:2], [None, None]) + sample = parsed["ethosu_pmu"][2] + self.assertIs(sample["product"], EthosUProduct.U85) + self.assertIs(sample["platform"], EthosUPlatform.CoreDriver) + self.assertEqual(sample["macs_per_cycle"], 256) + self.assertEqual(sample["cycle_count"], 123456) + self.assertEqual(len(sample["counters"]), 1) + counter = sample["counters"][0] + self.assertIs(counter["event_type"], EthosUPmuEvent.ETHOSU_PMU_NPU_IDLE) + self.assertEqual(counter["event_id"], 112) + self.assertEqual(counter["count"], 45) + self.assertFalse(counter["overflow"]) + self.assertEqual(parse_ethosu_delegate_pmu_metadata([b"other", ""]), {}) + + def test_rejects_invalid_metadata(self) -> None: + """Reject malformed Ethos-U records and unsupported event IDs.""" + valid = _metadata((97, 67, 1)) + invalid = { + "short header": (valid[:39], "shorter than its header"), + "wrong magic": (b"XYZ1" + valid[4:], "magic"), + "unsupported version": ( + b"EUC2" + valid[4:], + r"supported versions: \{1\}", + ), + "invalid version": (b"EUCx" + valid[4:], "version"), + "extra byte": (valid + b"\x00", "expected"), + "too many counters": ( + valid[:36] + struct.pack(" Date: Wed, 4 Nov 2026 11:03:32 +0100 Subject: [PATCH 043/128] Transforms: Materialize symbolic reshape shapes (#22821) Materialize symbolic dimensions before creating rewritten view_copy nodes. Adds symbolic_shape_utils to materialize such shapes. Preserve symbolic metadata during view and permute rewrites, skip view-map analysis for FX-node shape arguments, and retain valid graph ordering when fusing views. cc @digantdesai @freddan80 @per @zingo @mansnils @Sebastian-Larsson @robell @rascani --------- Signed-off-by: Oscar Andersson --- backends/arm/_passes/BUCK | 1 + .../resolve_view_copy_inferred_dim_pass.py | 2 +- ...ewrite_high_rank_singleton_permute_pass.py | 30 ++- backends/arm/_passes/symbolic_shape_utils.py | 27 --- .../_passes/symbolic_to_tosa_shape_pass.py | 2 +- ...est_canonicalize_view_copy_permute_pass.py | 219 +++++++++++++++++- ...ewrite_high_rank_singleton_permute_pass.py | 52 +++++ .../canonicalize_view_copy_permute_pass.py | 98 ++++++-- backends/transforms/dim_maps.py | 10 +- backends/transforms/permute_view_meta.py | 10 +- backends/transforms/symbolic_shape_utils.py | 64 +++++ backends/transforms/targets.bzl | 11 +- .../test_propagate_view_copy_permute_pass.py | 35 +++ 13 files changed, 498 insertions(+), 63 deletions(-) delete mode 100644 backends/arm/_passes/symbolic_shape_utils.py create mode 100644 backends/arm/test/passes/test_rewrite_high_rank_singleton_permute_pass.py create mode 100644 backends/transforms/symbolic_shape_utils.py diff --git a/backends/arm/_passes/BUCK b/backends/arm/_passes/BUCK index 3bc696a470a..a3fb65b6aff 100644 --- a/backends/arm/_passes/BUCK +++ b/backends/arm/_passes/BUCK @@ -66,6 +66,7 @@ fbcode_target( "//executorch/backends/arm/tosa/dialect:lib", "//executorch/backends/transforms:canonicalize_view_copy_permute_pass", "//executorch/backends/transforms:permute_view_meta", + "//executorch/backends/transforms:symbolic_shape_utils", "//executorch/backends/transforms:fuse_duplicate_users_pass", "//executorch/backends/transforms:propagate_view_copy_permute_pass", "//executorch/backends/transforms:fuse_identical_input_transforms_pass", diff --git a/backends/arm/_passes/resolve_view_copy_inferred_dim_pass.py b/backends/arm/_passes/resolve_view_copy_inferred_dim_pass.py index df63665586b..57f93133f60 100644 --- a/backends/arm/_passes/resolve_view_copy_inferred_dim_pass.py +++ b/backends/arm/_passes/resolve_view_copy_inferred_dim_pass.py @@ -6,10 +6,10 @@ import torch from executorch.backends.arm._passes.arm_pass import ArmPass -from executorch.backends.arm._passes.symbolic_shape_utils import materialize_symints from executorch.backends.arm._passes.symbolic_to_tosa_shape_pass import ( SymbolicToTosaShapesPass, ) +from executorch.backends.transforms.symbolic_shape_utils import materialize_symints from executorch.exir.dialects._ops import ops as exir_ops from executorch.exir.pass_base import PassResult diff --git a/backends/arm/_passes/rewrite_high_rank_singleton_permute_pass.py b/backends/arm/_passes/rewrite_high_rank_singleton_permute_pass.py index 40a7935f050..cde5a9e6428 100644 --- a/backends/arm/_passes/rewrite_high_rank_singleton_permute_pass.py +++ b/backends/arm/_passes/rewrite_high_rank_singleton_permute_pass.py @@ -5,9 +5,11 @@ from typing import Sequence, Set, Type +import torch from executorch.backends.arm._passes import ArmOpTargetedPass +from executorch.backends.transforms.symbolic_shape_utils import materialize_symints from executorch.exir.dialects._ops import ops as exir_ops -from executorch.exir.pass_base import ExportPass +from executorch.exir.pass_base import ExportPass, ProxyValue class RewriteHighRankSingletonPermutePass(ArmOpTargetedPass): @@ -93,7 +95,19 @@ def call_operator(self, op, args, kwargs, meta): reduced_input_shape = [input_shape[axis] for axis in non_singleton_axes] reduced_input = super().call_operator( exir_ops.edge.aten.view_copy.default, - (args[0], reduced_input_shape), + ( + args[0], + [ + ( + ProxyValue(dim.meta["val"], self.tracer.proxy(dim)) + if isinstance(dim, torch.fx.Node) + else dim + ) + for dim in materialize_symints( + self.tracer.graph, reduced_input_shape + ) + ], + ), {}, meta, ) @@ -108,7 +122,17 @@ def call_operator(self, op, args, kwargs, meta): ) return super().call_operator( exir_ops.edge.aten.view_copy.default, - (reduced_output, output_shape), + ( + reduced_output, + [ + ( + ProxyValue(dim.meta["val"], self.tracer.proxy(dim)) + if isinstance(dim, torch.fx.Node) + else dim + ) + for dim in materialize_symints(self.tracer.graph, output_shape) + ], + ), {}, meta, ) diff --git a/backends/arm/_passes/symbolic_shape_utils.py b/backends/arm/_passes/symbolic_shape_utils.py deleted file mode 100644 index ab4f525526a..00000000000 --- a/backends/arm/_passes/symbolic_shape_utils.py +++ /dev/null @@ -1,27 +0,0 @@ -# Copyright 2026 Arm Limited and/or its affiliates. -# -# This source code is licensed under the BSD-style license found in the -# LICENSE file in the root directory of this source tree. - -from typing import Sequence - -import torch - - -def materialize_symints( - graph: torch.fx.Graph, values: Sequence[int | torch.SymInt] -) -> list[torch.fx.Node | int]: - """Materialize symbolic integers into FX graph values. - - Args: - graph (torch.fx.Graph): Graph where producer nodes are inserted. - values (Sequence[int | torch.SymInt]): Integer values to materialize. - - Returns: - list[torch.fx.Node | int]: Materialized graph nodes or static integers. - - """ - materialized = graph.materialize_symints(values) - if not any(isinstance(value, torch.SymInt) for value in materialized): - return materialized - raise AssertionError("materialize_symints returned a raw SymInt") diff --git a/backends/arm/_passes/symbolic_to_tosa_shape_pass.py b/backends/arm/_passes/symbolic_to_tosa_shape_pass.py index 71f4e0c1102..f36027b78f1 100644 --- a/backends/arm/_passes/symbolic_to_tosa_shape_pass.py +++ b/backends/arm/_passes/symbolic_to_tosa_shape_pass.py @@ -14,8 +14,8 @@ from executorch.backends.arm._passes.symbolic_materialization_helper import ( SymbolMaterializationHelpers, ) -from executorch.backends.arm._passes.symbolic_shape_utils import materialize_symints from executorch.backends.arm.tosa.dialect.shape import meta_has_shape_mark +from executorch.backends.transforms.symbolic_shape_utils import materialize_symints from executorch.exir.dialects._ops import ops as exir_ops from executorch.exir.pass_base import NodeMetadata, ProxyValue diff --git a/backends/arm/test/passes/test_canonicalize_view_copy_permute_pass.py b/backends/arm/test/passes/test_canonicalize_view_copy_permute_pass.py index b1ba3335f58..5e01ffc0858 100644 --- a/backends/arm/test/passes/test_canonicalize_view_copy_permute_pass.py +++ b/backends/arm/test/passes/test_canonicalize_view_copy_permute_pass.py @@ -6,14 +6,17 @@ import copy from typing import cast +import pytest import sympy # type: ignore[import-untyped] import torch + from executorch.backends.arm._passes import CanonicalizeViewCopyPermutePass from executorch.backends.test.graph_builder import GraphBuilder from executorch.exir.dialects._ops import ops as exir_ops from executorch.exir.pass_base import PassResult from torch._subclasses.fake_tensor import FakeTensorMode -from torch.fx.experimental.symbolic_shapes import ShapeEnv +from torch.fx import Graph, GraphModule, Node +from torch.fx.experimental.symbolic_shapes import CallMethodKey, ShapeEnv from torch.utils import _pytree as pytree @@ -86,6 +89,28 @@ def test_canonicalize_direct_permute_chain() -> None: _validate_numerics(gm_before, result.graph_module, (x_data,)) +def test_canonicalize_custom_permute_refreshes_emitted_permute_meta() -> None: + builder = GraphBuilder() + x_data = torch.randn(2, 3, 4) + x = builder.placeholder("x", x_data) + custom_permute = exir_ops.edge.aten.permute.default + p1 = builder.call_operator(op=custom_permute, args=(x, [1, 0, 2])) + p2 = builder.call_operator(op=custom_permute, args=(p1, [0, 2, 1])) + builder.output([p2]) + graph_module = builder.get_graph_module() + permutes = [ + node for node in graph_module.graph.nodes if node.target == custom_permute + ] + + changed = CanonicalizeViewCopyPermutePass( + permute_targets=(custom_permute,) + )._fuse_pair(graph_module, permutes, 0) + + assert changed + assert permutes[0].target == exir_ops.edge.aten.permute_copy.default + assert tuple(permutes[0].meta["val"].shape) == (3, 4, 2) + + def test_canonicalize_singleton_permute_to_view() -> None: builder = GraphBuilder() x_data = torch.randn(1, 1, 1, 4) @@ -481,12 +506,22 @@ def test_canonicalize_symbolic_pixel_shuffle_view_permute_chain() -> None: ) assert _count_node(result.graph_module, exir_ops.edge.aten.view_copy.default) == 2 - compute_nodes = [ - node for node in result.graph_module.graph.nodes if node.op == "call_function" + def shape_values(shape): + return [dim.meta["val"] if isinstance(dim, Node) else dim for dim in shape] + + transform_nodes = [ + node + for node in result.graph_module.graph.nodes + if node.op == "call_function" + and node.target + in { + exir_ops.edge.aten.view_copy.default, + exir_ops.edge.aten.permute_copy.default, + } ] - assert compute_nodes[0].args[1] == [batch, 2, 2, 2, 2, 2] - assert compute_nodes[1].args[1] == [0, 1, 4, 2, 5, 3] - assert compute_nodes[2].args[1] == [batch, 4, 4, 2] + assert shape_values(transform_nodes[0].args[1]) == [batch, 2, 2, 2, 2, 2] + assert transform_nodes[1].args[1] == [0, 1, 4, 2, 5, 3] + assert shape_values(transform_nodes[2].args[1]) == [batch, 4, 4, 2] def test_canonicalize_symbolic_singleton_permute_stays_permute() -> None: @@ -645,3 +680,175 @@ def test_canonicalize_unsupported_end_view_does_not_block_prefix() -> None: ) assert _count_node(result.graph_module, exir_ops.edge.aten.view_copy.default) == 1 _validate_numerics(gm_before, result.graph_module, (x_data,)) + + +def test_canonicalize_skips_view_map_with_fx_node_shape_arg() -> None: + shape_env = ShapeEnv() + batch = _make_symint(shape_env, "batch", hint=2) + + with FakeTensorMode(shape_env=shape_env, allow_non_fake_inputs=True) as mode: + graph = Graph() + x = graph.placeholder("x") + x.meta["val"] = mode.from_tensor(torch.empty(size=(batch, 1, 1, 3))) + permute = graph.call_function( + exir_ops.edge.aten.permute_copy.default, + (x, [0, 3, 1, 2]), + ) + permute.meta["val"] = mode.from_tensor(torch.empty(size=(batch, 3, 1, 1))) + sym_size = graph.call_function(torch.ops.aten.sym_size.int, (x, 0)) + sym_size.meta["val"] = batch + view = graph.call_function( + exir_ops.edge.aten.view_copy.default, + (permute, [sym_size, 3, 1]), + ) + view.meta["val"] = mode.from_tensor(torch.empty(size=(batch, 3, 1))) + graph.output(view) + + graph_module = GraphModule({}, graph) + result = cast(PassResult, CanonicalizeViewCopyPermutePass().call(graph_module)) + + result.graph_module.graph.lint() + view_nodes = [ + node + for node in result.graph_module.graph.nodes + if node.op == "call_function" + and node.target == exir_ops.edge.aten.view_copy.default + ] + assert len(view_nodes) == 1 + shape_arg = view_nodes[0].args[1] + assert isinstance(shape_arg, list) + assert any(isinstance(dim, Node) for dim in shape_arg) + + +@pytest.mark.parametrize("shape_arg_is_node", (False, True)) +def test_canonicalize_keeps_later_symbol_producer_after_first_view( + shape_arg_is_node: bool, +) -> None: + shape_env = ShapeEnv() + dynamic_size = shape_env.create_unbacked_symint() + + with FakeTensorMode(shape_env=shape_env): + graph = Graph() + x = graph.placeholder("x") + x.meta["val"] = torch.empty((0, 2)) + y = graph.placeholder("y") + y.meta["val"] = torch.empty((3, 2)) + first_view = graph.call_function( + exir_ops.edge.aten.view_copy.default, (x, [2, 0]) + ) + first_view.meta["val"] = torch.empty((2, 0)) + nonzero = graph.call_function(torch.ops.aten.nonzero.default, (y,)) + nonzero.meta["val"] = torch.empty((dynamic_size, 2)) + nonzero.meta["unbacked_bindings"] = { + dynamic_size.node.expr: (CallMethodKey("size"), pytree.SequenceKey(0)) + } + size: torch.SymInt | Node = dynamic_size + if shape_arg_is_node: + size = graph.call_function(torch.ops.aten.sym_size.int, (nonzero, 0)) + size.meta["val"] = dynamic_size + second_view = graph.call_function( + exir_ops.edge.aten.view_copy.default, (first_view, [size, 0]) + ) + second_view.meta["val"] = torch.empty((dynamic_size, 0)) + graph.output(second_view) + + result = CanonicalizeViewCopyPermutePass().call(GraphModule({}, graph)) + + assert not result.modified + result.graph_module.graph.lint() + assert first_view.args[1] == [2, 0] + + +@pytest.mark.parametrize("shape_arg_is_node", (False, True)) +def test_canonicalize_keeps_symbolic_view_before_permute( + shape_arg_is_node: bool, +) -> None: + shape_env = ShapeEnv() + dynamic_size = shape_env.create_unbacked_symint() + + with FakeTensorMode(shape_env=shape_env): + graph = Graph() + y = graph.placeholder("y") + y.meta["val"] = torch.empty((3, 2)) + nonzero = graph.call_function(torch.ops.aten.nonzero.default, (y,)) + nonzero.meta["val"] = torch.empty((dynamic_size, 2)) + nonzero.meta["unbacked_bindings"] = { + dynamic_size.node.expr: (CallMethodKey("size"), pytree.SequenceKey(0)) + } + size: torch.SymInt | Node = dynamic_size + if shape_arg_is_node: + size = graph.call_function(torch.ops.aten.sym_size.int, (nonzero, 0)) + size.meta["val"] = dynamic_size + view = graph.call_function( + exir_ops.edge.aten.view_copy.default, (nonzero, [size, 1, 2]) + ) + view.meta["val"] = torch.empty((dynamic_size, 1, 2)) + permute = graph.call_function( + exir_ops.edge.aten.permute_copy.default, (view, [0, 2, 1]) + ) + permute.meta["val"] = torch.empty((dynamic_size, 2, 1)) + graph.output(permute) + + result = CanonicalizeViewCopyPermutePass().call(GraphModule({}, graph)) + + assert not result.modified + result.graph_module.graph.lint() + assert permute.args[0] is view + + +def test_canonicalize_keeps_affine_input_views_when_symbol_has_no_producer() -> None: + shape_env = ShapeEnv() + batch = _make_symint(shape_env, "batch", hint=2) + + with FakeTensorMode(shape_env=shape_env, allow_non_fake_inputs=True): + graph = Graph() + x = graph.placeholder("x") + x.meta["val"] = torch.empty((batch + 1, 2)) + first_view = graph.call_function( + exir_ops.edge.aten.view_copy.default, (x, [batch + 1, 1, 2]) + ) + first_view.meta["val"] = torch.empty((batch + 1, 1, 2)) + second_view = graph.call_function( + exir_ops.edge.aten.view_copy.default, (first_view, [batch + 1, 2]) + ) + second_view.meta["val"] = torch.empty((batch + 1, 2)) + graph.output(second_view) + + result = CanonicalizeViewCopyPermutePass().call(GraphModule({}, graph)) + + assert not result.modified + result.graph_module.graph.lint() + assert second_view.args[0] is first_view + + +@pytest.mark.parametrize("view_first", (False, True)) +def test_canonicalize_does_not_swap_affine_views_without_symbol_producer( + view_first: bool, +) -> None: + shape_env = ShapeEnv() + batch = _make_symint(shape_env, "batch", hint=2) + + with FakeTensorMode(shape_env=shape_env, allow_non_fake_inputs=True): + graph = Graph() + x = graph.placeholder("x") + x.meta["val"] = torch.empty((batch + 1, 2)) + if view_first: + first = graph.call_function( + exir_ops.edge.aten.view_copy.default, (x, [batch + 1, 1, 2]) + ) + first.meta["val"] = torch.empty((batch + 1, 1, 2)) + second = graph.call_function( + exir_ops.edge.aten.permute_copy.default, (first, [0, 2, 1]) + ) + second.meta["val"] = torch.empty((batch + 1, 2, 1)) + else: + first = graph.call_function( + exir_ops.edge.aten.permute_copy.default, (x, [1, 0]) + ) + first.meta["val"] = torch.empty((2, batch + 1)) + second = graph.call_function( + exir_ops.edge.aten.view_copy.default, (first, [2, 1, batch + 1]) + ) + second.meta["val"] = torch.empty((2, 1, batch + 1)) + + assert CanonicalizeViewCopyPermutePass()._maybe_swap_args(first, second) is None diff --git a/backends/arm/test/passes/test_rewrite_high_rank_singleton_permute_pass.py b/backends/arm/test/passes/test_rewrite_high_rank_singleton_permute_pass.py new file mode 100644 index 00000000000..abf8d9d4adf --- /dev/null +++ b/backends/arm/test/passes/test_rewrite_high_rank_singleton_permute_pass.py @@ -0,0 +1,52 @@ +# Copyright 2026 Arm Limited and/or its affiliates. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import torch + +from executorch.backends.arm._passes import RewriteHighRankSingletonPermutePass +from executorch.exir import to_edge +from executorch.exir.dialects._ops import ops as exir_ops +from torch.export import Dim, export +from torch.fx import Node + + +class _HighRankPermute(torch.nn.Module): + def forward(self, x: torch.Tensor) -> torch.Tensor: + return x.permute(0, 4, 1, 2, 3) + + +def test_rewrite_high_rank_permute_materializes_dynamic_view_shapes() -> None: + model = _HighRankPermute() + example = torch.randn(2, 1, 2, 1, 3) + exported = export( + model, + (example,), + dynamic_shapes={"x": {0: Dim("batch", min=1, max=8)}}, + ) + graph_module = to_edge(exported).exported_program().graph_module + + result = RewriteHighRankSingletonPermutePass()(graph_module) + assert result is not None + rewritten = result.graph_module + rewritten.graph.lint() + + views = [ + node + for node in rewritten.graph.nodes + if node.target == exir_ops.edge.aten.view_copy.default + ] + permutes = [ + node + for node in rewritten.graph.nodes + if node.target == exir_ops.edge.aten.permute_copy.default + ] + assert len(views) == 2 + assert all(any(isinstance(dim, Node) for dim in view.args[1]) for view in views) + assert len(permutes) == 1 + assert len(permutes[0].args[1]) == 3 + + for batch in (2, 4): + inputs = torch.randn(batch, 1, 2, 1, 3) + torch.testing.assert_close(rewritten(inputs)[0], model(inputs)) diff --git a/backends/transforms/canonicalize_view_copy_permute_pass.py b/backends/transforms/canonicalize_view_copy_permute_pass.py index 6d6feb6e30a..9ccfafa4534 100644 --- a/backends/transforms/canonicalize_view_copy_permute_pass.py +++ b/backends/transforms/canonicalize_view_copy_permute_pass.py @@ -9,18 +9,23 @@ from typing import Any, cast, Iterable, Sequence, Set, Type import torch + from executorch.backends.transforms.dim_maps import ( _dim_equals, _is_permutation, _normalize_dim, _normalize_dims, + normalize_view_shape, ViewMap, ) - -from executorch.backends.transforms.permute_view_meta import refresh_permute_view_meta +from executorch.backends.transforms.symbolic_shape_utils import ( + can_materialize_symints, + materialize_symints, +) from executorch.exir.dialects._ops import ops as exir_ops from executorch.exir.pass_base import ExportPass from torch.fx import GraphModule, Node +from torch.fx.experimental.symbolic_shapes import free_unbacked_symbols from torch.fx.node import Target from torch.fx.passes.infra.pass_base import PassResult @@ -168,7 +173,6 @@ def _fuse_sequential_ops( continue if self._is_permute(node): - dims = self._permute_dims(node) # Normalize dims @@ -231,10 +235,18 @@ def _fuse_pair( return True if node.target == self._VIEW_TARGET and next_node.target == self._VIEW_TARGET: - # Fuse conscutive views - self._set_node_op( - node, self._VIEW_TARGET, input_node, self._shape(next_node) - ) + shape = self._shape(next_node) + if ( + any(isinstance(dim, Node) for dim in next_node.args[1]) + or any( + isinstance(dim, torch.SymInt) and free_unbacked_symbols(dim) + for dim in shape + ) + or not can_materialize_symints(graph_module.graph, shape, node) + ): + return False + # Fuse consecutive views + self._set_node_op(node, self._VIEW_TARGET, input_node, shape) self._remove_node(graph_module, chain, index + 1, replacement=node) return True @@ -277,7 +289,9 @@ def _sink_singleton_view(self, chain: list[Node], index: int) -> bool: return False swapped_args = self._view_permute_swap(view_node, permute_node) - if swapped_args is None: + if swapped_args is None or not can_materialize_symints( + view_node.graph, swapped_args[1], permute_node + ): return False self._set_node_op(view_node, self._PERMUTE_TARGET, input_node, swapped_args[0]) @@ -295,12 +309,21 @@ def _maybe_swap_args( input_val = input_node.meta["val"] if self._is_permute(op1) and op2.target == self._VIEW_TARGET: - return self._permute_view_swap(input_val, op1, op2) - - if op1.target == self._VIEW_TARGET and self._is_permute(op2): - return self._view_permute_swap(op1, op2) + swapped_args = self._permute_view_swap(input_val, op1, op2) + view_node = op1 + view_arg_index = 0 + elif op1.target == self._VIEW_TARGET and self._is_permute(op2): + swapped_args = self._view_permute_swap(op1, op2) + view_node = op2 + view_arg_index = 1 + else: + return None - return None + if swapped_args is None or not can_materialize_symints( + view_node.graph, swapped_args[view_arg_index], view_node + ): + return None + return swapped_args def _permute_view_swap( self, input_val: torch.Tensor, permute_node: Node, view_node: Node @@ -396,8 +419,14 @@ def _is_singleton_permutation( ] return permuted_non_singleton_axes == non_singleton_axes - @staticmethod - def _view_map(view_node: Node) -> ViewMap | None: + @classmethod + def _view_map(cls, view_node: Node) -> ViewMap | None: + if any(isinstance(dim, Node) for dim in view_node.args[1]) or any( + isinstance(dim, torch.SymInt) and free_unbacked_symbols(dim) + for dim in cls._shape(view_node) + ): + return None + try: return ViewMap(view_node) except AssertionError: @@ -431,13 +460,48 @@ def _set_node_op( arg: Sequence[_Dim], ) -> None: node.target = target - node.args = (input_node, list(arg)) - refresh_permute_view_meta(node) + if target == self._VIEW_TARGET: + with node.graph.inserting_before(node): + shape_arg = materialize_symints(node.graph, arg) + else: + shape_arg = list(arg) + node.args = (input_node, shape_arg) + self._refresh_meta(node, arg) def _permute_dims(self, node: Node) -> list[int]: assert self._is_permute(node), "Expected permute node" return list(cast(Sequence[int], node.args[1])) + def _refresh_meta(self, node: Node, shape: Sequence[_Dim] | None = None) -> None: + input_node = node.args[0] + assert isinstance(input_node, Node) + input_val = input_node.meta.get("val") + is_permute = self._is_permute(node) or node.target == self._PERMUTE_TARGET + if input_val is None or (node.target != self._VIEW_TARGET and not is_permute): + return + + # Compute new meta shapes to preserve SymInts. + if isinstance(input_val, torch.Tensor): + if node.target == self._VIEW_TARGET: + view_shape = ( + shape if shape is not None else cast(Sequence[_Dim], node.args[1]) + ) + node.meta["val"] = input_val.new_empty( + tuple(normalize_view_shape(input_val.shape, view_shape)) + ) + return + + if is_permute: + dims = _normalize_dims( + cast(Sequence[int], node.args[1]), len(input_val.shape) + ) + node.meta["val"] = input_val.new_empty( + tuple(input_val.shape[dim] for dim in dims) + ) + return + + node.meta["val"] = node.target(input_val, *node.args[1:]) # type: ignore[operator] + @staticmethod def _shape(node: Node) -> list[_Dim]: return cast(list[_Dim], list(node.meta["val"].shape)) diff --git a/backends/transforms/dim_maps.py b/backends/transforms/dim_maps.py index d0170cd1280..e49381e5326 100644 --- a/backends/transforms/dim_maps.py +++ b/backends/transforms/dim_maps.py @@ -205,6 +205,14 @@ def normalize_view_shape( return normalized_shape +def view_shape_values(view_node: Node) -> list[_Dim]: + """Read view dimensions, including materialized FX shape arguments.""" + return [ + cast(_Dim, dim.meta["val"]) if isinstance(dim, Node) else dim + for dim in view_node.args[1] + ] + + class _UnionFind: def __init__(self, size: int) -> None: self.parents = list(range(size)) @@ -266,7 +274,7 @@ def __init__(self, view_node: Node) -> None: self.source_shape = _simplify_shape(cast(Sequence[_Dim], input_val.shape)) self.target_shape = normalize_view_shape( - self.source_shape, cast(Sequence[_Dim], view_node.args[1]) + self.source_shape, view_shape_values(view_node) ) self._groups = self._build_groups(self.source_shape, self.target_shape) diff --git a/backends/transforms/permute_view_meta.py b/backends/transforms/permute_view_meta.py index 18972329176..07a16ddd1c9 100644 --- a/backends/transforms/permute_view_meta.py +++ b/backends/transforms/permute_view_meta.py @@ -1,5 +1,6 @@ # Copyright (c) Meta Platforms, Inc. and affiliates. # All rights reserved. +# Copyright 2026 Arm Limited and/or its affiliates. # # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. @@ -11,10 +12,11 @@ import torch import torch.fx + from executorch.backends.transforms.dim_maps import ( - _Dim, _normalize_dims, normalize_view_shape, + view_shape_values, ) from executorch.exir.dialects._ops import ops as exir_ops @@ -39,11 +41,7 @@ def refresh_permute_view_meta(node: torch.fx.Node) -> None: match node.target: case exir_ops.edge.aten.view_copy.default: node.meta["val"] = input_val.new_empty( - tuple( - normalize_view_shape( - input_val.shape, cast(Sequence[_Dim], node.args[1]) - ) - ) + tuple(normalize_view_shape(input_val.shape, view_shape_values(node))) ) case exir_ops.edge.aten.permute_copy.default: dims = _normalize_dims( diff --git a/backends/transforms/symbolic_shape_utils.py b/backends/transforms/symbolic_shape_utils.py new file mode 100644 index 00000000000..1b3aa419c3b --- /dev/null +++ b/backends/transforms/symbolic_shape_utils.py @@ -0,0 +1,64 @@ +# Copyright 2026 Arm Limited and/or its affiliates. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +from collections.abc import Sequence + +import sympy # type: ignore[import-untyped] +import torch + + +def materialize_symints( + graph: torch.fx.Graph, values: Sequence[int | torch.SymInt] +) -> list[torch.fx.Node | int]: + """Materialize symbolic integers into FX graph values.""" + materialized = graph.materialize_symints(values) + if any(isinstance(value, torch.SymInt) for value in materialized): + raise AssertionError("materialize_symints returned a raw SymInt") + return materialized + + +def _shape_source_symbol(expr: sympy.Expr) -> sympy.Symbol | None: + coefficient, symbol = expr.as_coeff_Mul() + if ( + isinstance(coefficient, sympy.Integer) + and coefficient > 0 + and isinstance(symbol, sympy.Symbol) + ): + return symbol + return None + + +def can_materialize_symints( + graph: torch.fx.Graph, + values: Sequence[int | torch.SymInt], + before: torch.fx.Node, +) -> bool: + """Check that every symbol has a source before changing the graph.""" + if not any(isinstance(value, torch.SymInt) for value in values): + return True + + available: set[sympy.Symbol] = set() + for node in graph.nodes: + if node is before: + break + if node.op == "placeholder": + value = node.meta.get("val", node.meta.get("example_value")) + if isinstance(value, torch.SymInt): + expr = value.node.expr + if isinstance(expr, sympy.Symbol): + available.add(expr) + elif isinstance(value, torch.Tensor): + for dim in value.shape: + if isinstance(dim, torch.SymInt): + symbol = _shape_source_symbol(dim.node.expr) + if symbol is not None: + available.add(symbol) + else: + available.update(node.meta.get("unbacked_bindings", ())) + + return all( + not isinstance(value, torch.SymInt) or value.node.expr.free_symbols <= available + for value in values + ) diff --git a/backends/transforms/targets.bzl b/backends/transforms/targets.bzl index 4cd4db88fa6..34fd26739cf 100644 --- a/backends/transforms/targets.bzl +++ b/backends/transforms/targets.bzl @@ -395,12 +395,21 @@ def define_common_targets(): deps = [ "//caffe2:torch", ":dim_maps", - ":permute_view_meta", + ":symbolic_shape_utils", "//executorch/exir:pass_base", "//executorch/exir/dialects:lib", ], ) + runtime.python_library( + name = "symbolic_shape_utils", + srcs = ["symbolic_shape_utils.py"], + visibility = ["//executorch/backends/..."], + deps = [ + "//caffe2:torch", + ], + ) + runtime.python_library( name = "dim_maps", srcs = [ diff --git a/backends/transforms/test/test_propagate_view_copy_permute_pass.py b/backends/transforms/test/test_propagate_view_copy_permute_pass.py index f00d8a18418..3f2181905db 100644 --- a/backends/transforms/test/test_propagate_view_copy_permute_pass.py +++ b/backends/transforms/test/test_propagate_view_copy_permute_pass.py @@ -8,12 +8,16 @@ from collections.abc import Sequence import pytest +import sympy # type: ignore[import-untyped] import torch + from executorch.backends.transforms.propagate_view_copy_permute_pass import ( PropagateViewCopyPermuteDownPass, PropagateViewCopyPermuteUpPass, ) from executorch.exir.dialects._ops import ops as exir_ops +from torch._subclasses.fake_tensor import FakeTensorMode +from torch.fx.experimental.symbolic_shapes import ShapeEnv PERMUTE = exir_ops.edge.aten.permute_copy.default ABS = exir_ops.edge.aten.abs.default @@ -23,6 +27,37 @@ SUM = exir_ops.edge.aten.sum.dim_IntList WHERE = exir_ops.edge.aten.where.self + +def test_down_pass_propagates_view_with_materialized_shape() -> None: + shape_env = ShapeEnv() + batch = shape_env.create_symintnode(sympy.Symbol("batch"), hint=2) + assert isinstance(batch, torch.SymInt) + shape_env.constrain_symbol_range(batch.node.expr, compiler_min=1, compiler_max=64) + + with FakeTensorMode(shape_env=shape_env, allow_non_fake_inputs=True): + graph = torch.fx.Graph() + x = graph.placeholder("x") + x.meta["val"] = torch.empty((batch, 6)) + size = graph.call_function(torch.ops.aten.sym_size.int, (x, 0)) + size.meta["val"] = batch + view = graph.call_function( + exir_ops.edge.aten.view_copy.default, (x, [size, 1, 2, 3]) + ) + view.meta["val"] = torch.empty((batch, 1, 2, 3)) + relu = graph.call_function(exir_ops.edge.aten.relu.default, (view,)) + relu.meta["val"] = torch.empty((batch, 1, 2, 3)) + graph.output(relu) + + result = PropagateViewCopyPermuteDownPass().call( + torch.fx.GraphModule({}, graph) + ) + + assert result.modified + result.graph_module.graph.lint() + assert view.args[0] is relu + assert tuple(view.meta["val"].shape) == (batch, 1, 2, 3) + + SOURCE_SHAPE = (1, 2, 3, 4) PERMUTED_SHAPE = (1, 3, 4, 2) PERMUTATION = [0, 2, 3, 1] From 3d6a1f59c6453276139541cb8cdb87f6e8c2bd1b Mon Sep 17 00:00:00 2001 From: Oscar Andersson <87121123+oscarandersson8218@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:19:27 +0100 Subject: [PATCH 044/128] exir: Decompose normal_float_float (#22833) Decompose aten::normal.float_float into randn * std + mean as there is no portable kernel for normal_float_float. cc @digantdesai @freddan80 @per @zingo @mansnils @Sebastian-Larsson @robell @rascani --------- Signed-off-by: Oscar Andersson --- .../test/misc/test_normal_decomposition.py | 43 ++++++ backends/arm/test/ops/test_normal.py | 68 +++++++++ .../test_default_normal_decomposition.py | 138 ++++++++++++++++++ exir/tracer.py | 39 +++++ 4 files changed, 288 insertions(+) create mode 100644 backends/arm/test/misc/test_normal_decomposition.py create mode 100644 backends/arm/test/ops/test_normal.py create mode 100644 exir/tests/test_default_normal_decomposition.py diff --git a/backends/arm/test/misc/test_normal_decomposition.py b/backends/arm/test/misc/test_normal_decomposition.py new file mode 100644 index 00000000000..1aec241e3fe --- /dev/null +++ b/backends/arm/test/misc/test_normal_decomposition.py @@ -0,0 +1,43 @@ +# Copyright 2026 Arm Limited and/or its affiliates. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import operator + +import torch +from executorch.backends.arm.tosa.compile_spec import TosaCompileSpec +from executorch.backends.arm.tosa.partitioner import TOSAPartitioner +from executorch.exir import to_edge_transform_and_lower +from executorch.exir.dialects._ops import ops as exir_ops +from torch.export import export + + +class Normal(torch.nn.Module): + def forward(self, x: torch.Tensor) -> torch.Tensor: + return x + torch.normal(2.0, 3.0, size=x.shape) + + +def test_decomposed_normal_partitions_randn_to_portable() -> None: + exported_program = export(Normal(), (torch.zeros(2, 3),)) + partitioner = TOSAPartitioner(TosaCompileSpec("TOSA-1.0+FP")) + + edge_manager = to_edge_transform_and_lower( + exported_program, + partitioner=[partitioner], + ) + targets = { + node.target + for node in edge_manager.exported_program().graph.nodes + if node.op == "call_function" + } + + assert torch.ops.higher_order.executorch_call_delegate in targets + assert operator.getitem in targets + assert exir_ops.edge.aten.randn.default in targets + assert not any("normal" in str(target) for target in targets) + + program = edge_manager.to_executorch().executorch_program + operators = {(op.name, op.overload) for op in program.execution_plan[0].operators} + assert ("aten::randn", "out") in operators + assert not any("normal" in name for name, _ in operators) diff --git a/backends/arm/test/ops/test_normal.py b/backends/arm/test/ops/test_normal.py new file mode 100644 index 00000000000..6232e9bb1c6 --- /dev/null +++ b/backends/arm/test/ops/test_normal.py @@ -0,0 +1,68 @@ +# Copyright 2026 Arm Limited and/or its affiliates. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import torch +from executorch.backends.arm.test.tester.test_pipeline import TosaPipelineFP + + +input_t = tuple[torch.Tensor] +MEAN = 2.0 +STD = 3.0 +NUM_SAMPLES = 100_000 + + +class Normal(torch.nn.Module): + aten_op = "torch.ops.aten.normal.float_float" + exir_op = "executorch_exir_dialects_edge__ops_aten_normal_float_float" + randn_exir_op = "executorch_exir_dialects_edge__ops_aten_randn_default" + + def forward(self, x: torch.Tensor) -> torch.Tensor: + return x + torch.normal(MEAN, STD, size=x.shape) + + +def _compare_normal_statistics( + reference: torch.Tensor, + output: torch.Tensor, + _qparams, +) -> None: + assert output.shape == reference.shape + assert output.dtype == reference.dtype + assert torch.isfinite(output).all() + + torch.testing.assert_close( + output.mean(), + torch.tensor(MEAN), + atol=0.06, + rtol=0.0, + ) + torch.testing.assert_close( + output.std(correction=0), + torch.tensor(STD), + atol=0.05, + rtol=0.0, + ) + + +def test_normal_tosa_FP() -> None: + inputs = (torch.zeros(NUM_SAMPLES),) + pipeline = TosaPipelineFP[input_t]( + Normal(), + inputs, + Normal.aten_op, + Normal.exir_op, + run_on_tosa_ref_model=False, + ) + pipeline.add_stage_after( + "to_edge_transform_and_lower", + pipeline.tester.check, + [Normal.randn_exir_op], + suffix="portable_randn", + ) + pipeline.add_stage( + pipeline.tester.run_method_and_compare_outputs, + inputs=inputs, + compare_callback=_compare_normal_statistics, + ) + pipeline.run() diff --git a/exir/tests/test_default_normal_decomposition.py b/exir/tests/test_default_normal_decomposition.py new file mode 100644 index 00000000000..f9deb2dae9b --- /dev/null +++ b/exir/tests/test_default_normal_decomposition.py @@ -0,0 +1,138 @@ +# Copyright 2026 Arm Limited and/or its affiliates. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import pytest +import torch + +from executorch.exir.tracer import ( + _decompose_normal_float_float, + _default_decomposition_table, +) +from torch.export import export + + +class Normal(torch.nn.Module): + def forward(self, x: torch.Tensor) -> torch.Tensor: + return x + torch.normal(2.0, 3.0, size=x.shape) + + +class NormalWithGenerator(torch.nn.Module): + def __init__(self) -> None: + super().__init__() + self.generator = torch.Generator().manual_seed(0) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + return x + torch.normal( + 2.0, + 3.0, + size=x.shape, + generator=self.generator, + ) + + +class NoGradNormal(torch.nn.Module): + @torch.no_grad() + def forward(self, x: torch.Tensor) -> torch.Tensor: + return x + torch.normal(2.0, 3.0, size=x.shape) + + +class NormalWithDtype(torch.nn.Module): + def __init__(self, dtype: torch.dtype) -> None: + super().__init__() + self.dtype = dtype + + def forward(self, x: torch.Tensor) -> torch.Tensor: + return torch.normal(2.0, 3.0, size=x.shape, dtype=self.dtype) + + +def test_decompose_normal_float_float() -> None: + exported_program = export(Normal(), (torch.zeros(2, 3),)) + + transformed = exported_program.run_decompositions(_default_decomposition_table()) + targets = { + node.target for node in transformed.graph.nodes if node.op == "call_function" + } + + assert torch.ops.aten.normal.float_float not in targets + assert torch.ops.aten.randn.default in targets + assert torch.ops.aten.mul.Tensor in targets + assert torch.ops.aten.add.Tensor in targets + + +@pytest.mark.parametrize("dtype", (torch.float16, torch.complex64)) +def test_decompose_normal_float_float_with_dtype(dtype: torch.dtype) -> None: + exported_program = export(NormalWithDtype(dtype), (torch.zeros(2, 3),)) + + transformed = exported_program.run_decompositions(_default_decomposition_table()) + targets = { + node.target for node in transformed.graph.nodes if node.op == "call_function" + } + + assert torch.ops.aten.normal.float_float not in targets + assert torch.ops.aten.randn.default in targets + assert (torch.ops.aten._to_copy.default in targets) == (dtype == torch.float16) + + +@pytest.mark.parametrize("dtype", (torch.float16, torch.bfloat16)) +def test_decompose_normal_float_float_low_precision_numerical_correctness( + dtype: torch.dtype, +) -> None: + torch.manual_seed(0) + expected = torch.normal(2.0, 3.0, size=(1,), dtype=dtype) + torch.manual_seed(0) + actual = _decompose_normal_float_float(2.0, 3.0, (1,), dtype=dtype) + + torch.testing.assert_close(actual, expected) + + +def test_decompose_normal_float_float_avoids_float16_overflow() -> None: + torch.manual_seed(0) + expected = torch.normal(-50_000.0, 50_000.0, size=(1,), dtype=torch.float16) + torch.manual_seed(0) + sample = torch.randn((1,), dtype=torch.float16) + assert torch.isinf(sample * 50_000.0).all() + torch.manual_seed(0) + actual = _decompose_normal_float_float( + -50_000.0, 50_000.0, (1,), dtype=torch.float16 + ) + + assert torch.isfinite(actual).all() + torch.testing.assert_close(actual, expected) + + +@pytest.mark.parametrize("dtype", (torch.complex32, torch.complex64, torch.complex128)) +def test_decompose_normal_float_float_complex_numerical_correctness( + dtype: torch.dtype, +) -> None: + torch.manual_seed(0) + expected = torch.normal(2.0, 3.0, size=(8,), dtype=dtype) + torch.manual_seed(0) + actual = _decompose_normal_float_float(2.0, 3.0, (8,), dtype=dtype) + + torch.testing.assert_close(actual, expected) + + +def test_preserve_normal_float_float_with_generator() -> None: + exported_program = export(NormalWithGenerator(), (torch.zeros(2, 3),)) + + transformed = exported_program.run_decompositions(_default_decomposition_table()) + targets = { + node.target for node in transformed.graph.nodes if node.op == "call_function" + } + + assert torch.ops.aten.normal.float_float in targets + assert torch.ops.aten.randn.default not in targets + + +def test_decompose_normal_float_float_in_no_grad_submodule() -> None: + exported_program = export(NoGradNormal(), (torch.zeros(2, 3),)) + + transformed = exported_program.run_decompositions(_default_decomposition_table()) + targets = { + node.target for node in transformed.graph.nodes if node.op == "call_function" + } + + assert torch.ops.aten.normal.float_float not in targets + assert torch.ops.aten.randn.default in targets diff --git a/exir/tracer.py b/exir/tracer.py index 822abc3c021..a6170bc43b1 100644 --- a/exir/tracer.py +++ b/exir/tracer.py @@ -1,5 +1,6 @@ # Copyright (c) Meta Platforms, Inc. and affiliates. # All rights reserved. +# Copyright 2026 Arm Limited and/or its affiliates. # # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. @@ -19,6 +20,7 @@ Iterable, List, Optional, + Sequence, Set, Tuple, Union, @@ -652,6 +654,7 @@ def _default_decomposition_table( return get_decompositions(decomp_opset) # pyre-fixme[7] decomps = default_decompositions() + decomps[torch.ops.aten.normal.float_float] = _decompose_normal_float_float # Add edge specific decompositions additional_decomp_ops = [ # TODO: Eventually this op should be added to the core decompo table, and will not @@ -675,6 +678,42 @@ def _default_decomposition_table( return decomps # pyre-fixme[7] +def _decompose_normal_float_float( + mean: float, + std: float, + size: Sequence[int], + *, + generator: Optional[torch.Generator] = None, + dtype: Optional[torch.dtype] = None, + layout: Optional[torch.layout] = None, + device: Optional[torch.device] = None, + pin_memory: Optional[bool] = None, +) -> Any: + if generator is not None: + return NotImplemented + if std < 0: + raise RuntimeError(f"normal expects std >= 0.0, but found std {std}") + + output_dtype = dtype or torch.get_default_dtype() + match output_dtype: + case torch.float16 | torch.bfloat16: + compute_dtype = torch.float32 + case torch.complex32: + compute_dtype = torch.complex64 + case _: + compute_dtype = output_dtype + + result = torch.randn( + size, + dtype=compute_dtype, + layout=layout, + device=device, + pin_memory=pin_memory, + ) + result = result * std + (complex(mean, mean) if output_dtype.is_complex else mean) + return result.to(output_dtype) if compute_dtype != output_dtype else result + + def dynamo_trace( f: Callable[..., Value], # pyre-ignore From 1c0fef02b87fc91ddaaca613db5b21b7399f0b5e Mon Sep 17 00:00:00 2001 From: Digant Desai Date: Wed, 4 Nov 2026 05:49:46 -0600 Subject: [PATCH 045/128] XNNPACK: Skip the bf16 GEMM runtime tests in fbcode Summary: Skip three of the new bf16 tests in fbcode: `test_bf16_bmm`, `test_enable_bf16_fuses_batch_norm_into_a_linear` and `test_enable_bf16_runs_an_attention_block`. They are unchanged and still run in OSS. The check is `is_fbcode()` from `executorch.export.utils`. Differential Revision: D129426465 Pull Request resolved: https://github.com/pytorch/executorch/pull/24527 --- backends/xnnpack/test/BUCK | 2 ++ backends/xnnpack/test/ops/test_bmm.py | 5 +++++ backends/xnnpack/test/test_xnnpack_partitioner.py | 9 +++++++++ 3 files changed, 16 insertions(+) diff --git a/backends/xnnpack/test/BUCK b/backends/xnnpack/test/BUCK index 3e28be745d0..9392ae12a50 100644 --- a/backends/xnnpack/test/BUCK +++ b/backends/xnnpack/test/BUCK @@ -109,6 +109,7 @@ fbcode_target(_kind = runtime.python_test, "//executorch/exir:lib", "//executorch/exir/capture:config", "//executorch/exir/passes:constant_prop_pass", + "//executorch/export:export_utils", "//executorch/runtime:runtime", "//pytorch/ao:torchao", # @manual ], @@ -191,6 +192,7 @@ fbcode_target(_kind = runtime.python_test, "//caffe2:torch", "//executorch/backends/xnnpack/partition:xnnpack_partitioner", "//executorch/exir:lib", + "//executorch/export:export_utils", "//executorch/extension/pybindings:portable_lib", ], ) diff --git a/backends/xnnpack/test/ops/test_bmm.py b/backends/xnnpack/test/ops/test_bmm.py index 49d0112a19c..0d8fd41c33b 100644 --- a/backends/xnnpack/test/ops/test_bmm.py +++ b/backends/xnnpack/test/ops/test_bmm.py @@ -9,6 +9,7 @@ import torch from executorch.backends.xnnpack.partition.xnnpack_partitioner import XnnpackPartitioner from executorch.backends.xnnpack.test.tester import Tester, ToEdgeTransformAndLower +from executorch.export.utils import is_fbcode class TestBMM(unittest.TestCase): @@ -49,6 +50,10 @@ def test_fp32_bmm(self): ) self._test_bmm(inputs) + @unittest.skipIf( + is_fbcode(), + "skipping for fbcode", + ) def test_bf16_bmm(self): inputs = ( torch.randn(2, 3, 4).to(torch.bfloat16), diff --git a/backends/xnnpack/test/test_xnnpack_partitioner.py b/backends/xnnpack/test/test_xnnpack_partitioner.py index be775e5d784..3e450983d60 100644 --- a/backends/xnnpack/test/test_xnnpack_partitioner.py +++ b/backends/xnnpack/test/test_xnnpack_partitioner.py @@ -13,6 +13,7 @@ from executorch.backends.xnnpack.partition.xnnpack_partitioner import XnnpackPartitioner from executorch.exir import to_edge, to_edge_transform_and_lower +from executorch.export.utils import is_fbcode from executorch.extension.pybindings.portable_lib import ( _load_for_executorch_from_buffer, ) @@ -219,6 +220,10 @@ def lower(module, x): ).forward((bf16_x,))[0] torch.testing.assert_close(output, module(bf16_x)) + @unittest.skipIf( + is_fbcode(), + "skipping for fbcode", + ) def test_enable_bf16_fuses_batch_norm_into_a_linear(self): """ XNNPACK cannot define a bf16 BatchNorm on its own, but one that follows a @@ -259,6 +264,10 @@ def forward(self, x): ).forward((x,))[0] torch.testing.assert_close(output, module(x), atol=1e-2, rtol=1e-2) + @unittest.skipIf( + is_fbcode(), + "skipping for fbcode", + ) def test_enable_bf16_runs_an_attention_block(self): """ The linear layers and both batch matrix multiplications of a bf16 attention From 5343f204e5ada67d82cb3b32423c3623bb894a32 Mon Sep 17 00:00:00 2001 From: Digant Desai Date: Wed, 4 Nov 2026 05:50:15 -0600 Subject: [PATCH 046/128] Arm backend: Fix Buck targets for Ethos-U execution stats Summary: The Ethos-U execution stats change added `EthosUBackend_Pmu.h` and two Buck targets, `ethosu_invocation_context` and `ethosu_execution_stats`. Two Buck builds broke: - `ethosu_planned_scratch_test` compiles `EthosUBackend.cpp`, which now includes `EthosUBackend_Pmu.h`, but the test did not depend on `ethosu_invocation_context`, so the header was not found. Add the dependency. - `ethosu_execution_stats` talks to the Ethos-U core driver PMU, like `arm_backend`, but unlike `arm_backend` it was not restricted to the embedded Arm CPUs, so host builds tried to compile it. Give it the same `compatible_with` as `arm_backend`. cc @freddan80 @per @zingo @oscarandersson8218 @mansnils @Sebastian-Larsson @robell @rascani Differential Revision: D129418938 Pull Request resolved: https://github.com/pytorch/executorch/pull/24574 --- backends/arm/runtime/targets.bzl | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backends/arm/runtime/targets.bzl b/backends/arm/runtime/targets.bzl index 75cb47c7bbc..7bed02c281e 100644 --- a/backends/arm/runtime/targets.bzl +++ b/backends/arm/runtime/targets.bzl @@ -31,6 +31,7 @@ def define_common_targets(): runtime.cxx_library( name = "ethosu_execution_stats", srcs = ["EthosUExecutionStats.cpp"], + compatible_with = ["ovr_config//cpu:arm32-embedded", "ovr_config//cpu:arm32-embedded-fpu"], exported_headers = ["EthosUExecutionStats.h"], exported_deps = [":ethosu_invocation_context"], visibility = ["PUBLIC"], @@ -85,6 +86,7 @@ def define_common_targets(): compiler_flags = ["-Wno-global-constructors"], preprocessor_flags = _persistent_region_flags(), deps = [ + ":ethosu_invocation_context", ":vela_bin_stream", "//executorch/runtime/backend:interface", "//executorch/runtime/core:core", From b65dde9f12e0081bd144f8212c244c3010a2ed27 Mon Sep 17 00:00:00 2001 From: Digant Desai Date: Wed, 4 Nov 2026 07:12:32 -0600 Subject: [PATCH 047/128] Move the int64 div mode tests to the portable op_div test Summary: The int64 precision change for portable div added three tests for `div.out_mode` and `div.Scalar_mode_out` to `kernels/test/op_div_test.cpp`. That file is built for every kernel library that implements `div`, including the optimized kernels, and the optimized kernels have no mode variants. In the Buck build, the optimized test only sees the optimized `Functions.h`, so it fails to compile: ``` error: no matching function for call to 'div_outf' torch::executor::aten::div_outf(context_, a, b, "trunc", out); note: candidate function not viable: requires 4 arguments, but 5 were provided ``` `kernels/portable/test/op_div_test.cpp` already holds the portable-only mode tests, so move the three tests there, unchanged apart from the fixture names. Differential Revision: D129419916 Pull Request resolved: https://github.com/pytorch/executorch/pull/24565 --- kernels/portable/test/op_div_test.cpp | 49 +++++++++++++++++++++++++++ kernels/test/op_div_test.cpp | 49 --------------------------- 2 files changed, 49 insertions(+), 49 deletions(-) diff --git a/kernels/portable/test/op_div_test.cpp b/kernels/portable/test/op_div_test.cpp index b3b820da509..389046dcdb5 100644 --- a/kernels/portable/test/op_div_test.cpp +++ b/kernels/portable/test/op_div_test.cpp @@ -108,3 +108,52 @@ TEST_F(OpDivScalarModeOutKernelTest, SanityCheckModeFloor) { // Check that it matches the expected output. EXPECT_TENSOR_EQ(out, tf.make(sizes, {0, 1, 2, -5})); } + +TEST_F(OpDivScalarOutKernelTest, TruncModeKeepsInt64Precision) { + TensorFactory tf; + // Values above 2^53, which a double cannot hold exactly. + Tensor a = tf.make( + {3}, + {(int64_t{1} << 62) + 3, -((int64_t{1} << 62) + 3), 9007199254740993}); + Tensor b = tf.make({3}, {3, 3, 1}); + Tensor out = tf.zeros({3}); + + torch::executor::aten::div_outf(context_, a, b, "trunc", out); + EXPECT_TENSOR_EQ( + out, + tf.make( + {3}, {1537228672809129302, -1537228672809129302, 9007199254740993})); +} + +TEST_F(OpDivScalarModeOutKernelTest, TruncModeKeepsInt64Precision) { + TensorFactory tf; + // Values above 2^53, which a double cannot hold exactly. + Tensor a = tf.make( + {3}, + {(int64_t{1} << 62) + 3, -((int64_t{1} << 62) + 3), 9007199254740993}); + Tensor out = tf.zeros({3}); + + torch::executor::aten::div_outf(context_, a, Scalar(3), "trunc", out); + EXPECT_TENSOR_EQ( + out, + tf.make( + {3}, {1537228672809129302, -1537228672809129302, 3002399751580331})); +} + +TEST_F(OpDivScalarModeOutKernelTest, ModeIntegerScalarIsCastToTensorDtype) { + TensorFactory tf; + Tensor a = tf.make({3}, {5, -7, 100}); + Tensor out = tf.zeros({3}); + + // Like ATen, the scalar takes the tensor's dtype: 255 is -1 as int8. + torch::executor::aten::div_outf(context_, a, Scalar(255), "trunc", out); + EXPECT_TENSOR_EQ(out, tf.make({3}, {-5, 7, -100})); + + // 256 is 0 as int8. + ET_EXPECT_KERNEL_FAILURE( + context_, + torch::executor::aten::div_outf(context_, a, Scalar(256), "trunc", out)); + ET_EXPECT_KERNEL_FAILURE( + context_, + torch::executor::aten::div_outf(context_, a, Scalar(256), "floor", out)); +} diff --git a/kernels/test/op_div_test.cpp b/kernels/test/op_div_test.cpp index 028757fa790..d313e85a338 100644 --- a/kernels/test/op_div_test.cpp +++ b/kernels/test/op_div_test.cpp @@ -562,22 +562,6 @@ TEST_F(OpDivOutTest, DISABLED_DynamicShapeUnbound) { EXPECT_TENSOR_CLOSE(out, expected_result); } -TEST_F(OpDivOutTest, TruncModeKeepsInt64Precision) { - TensorFactory tf; - // Values above 2^53, which a double cannot hold exactly. - Tensor a = tf.make( - {3}, - {(int64_t{1} << 62) + 3, -((int64_t{1} << 62) + 3), 9007199254740993}); - Tensor b = tf.make({3}, {3, 3, 1}); - Tensor out = tf.zeros({3}); - - torch::executor::aten::div_outf(context_, a, b, "trunc", out); - EXPECT_TENSOR_EQ( - out, - tf.make( - {3}, {1537228672809129302, -1537228672809129302, 9007199254740993})); -} - TEST_F(OpDivScalarOutTest, SanityCheckIntScalar) { TensorFactory tf_a; TensorFactory tf_out; @@ -606,39 +590,6 @@ TEST_F(OpDivScalarOutTest, SanityCheckFloatScalar) { EXPECT_TENSOR_EQ(out, tf_out.make(sizes, {0.5, 1.0, 2.0, -4.5})); } -TEST_F(OpDivScalarOutTest, TruncModeKeepsInt64Precision) { - TensorFactory tf; - // Values above 2^53, which a double cannot hold exactly. - Tensor a = tf.make( - {3}, - {(int64_t{1} << 62) + 3, -((int64_t{1} << 62) + 3), 9007199254740993}); - Tensor out = tf.zeros({3}); - - torch::executor::aten::div_outf(context_, a, Scalar(3), "trunc", out); - EXPECT_TENSOR_EQ( - out, - tf.make( - {3}, {1537228672809129302, -1537228672809129302, 3002399751580331})); -} - -TEST_F(OpDivScalarOutTest, ModeIntegerScalarIsCastToTensorDtype) { - TensorFactory tf; - Tensor a = tf.make({3}, {5, -7, 100}); - Tensor out = tf.zeros({3}); - - // Like ATen, the scalar takes the tensor's dtype: 255 is -1 as int8. - torch::executor::aten::div_outf(context_, a, Scalar(255), "trunc", out); - EXPECT_TENSOR_EQ(out, tf.make({3}, {-5, 7, -100})); - - // 256 is 0 as int8. - ET_EXPECT_KERNEL_FAILURE( - context_, - torch::executor::aten::div_outf(context_, a, Scalar(256), "trunc", out)); - ET_EXPECT_KERNEL_FAILURE( - context_, - torch::executor::aten::div_outf(context_, a, Scalar(256), "floor", out)); -} - TEST_F(OpDivScalarOutTest, OptimizedSanityCheck) { TensorFactory tf; From df036418d79de74aec8750966e679368cf5c6c13 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Wed, 4 Nov 2026 05:46:57 -0800 Subject: [PATCH 048/128] Arm backend: fix wrong FP outputs when one Conv2d folds two BatchNorms or scales (#24589) ### What is wrong The Arm backend can give silently wrong results when one `Conv2d` is used twice and each result goes into its own BatchNorm, or is multiplied by its own number: ```python def forward(self, x, y): return self.bn1(self.conv(x)), self.bn2(self.conv(y)) # or: return 0.25 * self.conv(x), 3.0 * self.conv(y) ``` `FuseBatchNorm2dPass` and `FoldScalarMulIntoConvPass` fold the BatchNorm or the multiplication into the convolution's weight and bias. They named the new weight and bias only after the convolution's own weight and bias, so both folds asked for the same name. The helper that creates constants returns the existing one when a name is taken. So the second convolution used the first fold's weight and bias, and the second output was wrong. ### What this change does The names of the folded weight and bias now also include the BatchNorm or multiplication node. That node is unique, so each fold gets its own constants. A model with one BatchNorm or one multiplication per convolution folds the same values as before. Only the names of the folded constants change, and nothing in the repository depends on the old names. This fixes floating-point (FP) lowering. Quantized models fold BatchNorm earlier, in torchao's `prepare_pt2e`, and that fold has the same problem for a shared `Conv2d`. That is not changed here. ### Testing - 2 new tests run the models above through the TOSA FP pipeline on the reference model. Without the fix the second output is off by up to 1.8 (BatchNorm) and 5.3 (multiplication). With the fix both match. - The tests in `backends/arm/test/passes`, and the TOSA FP and INT tests in `test_batch_norm.py` and `test_conv2d.py`, pass before and after. Tested on Linux x86_64 with torch 2.14 (CPU). Ethos-U and VGF targets were not run locally. cc @digantdesai @freddan80 @per @zingo @oscarandersson8218 @mansnils @Sebastian-Larsson @robell @rascani --- .../_passes/fold_scalar_mul_into_conv_pass.py | 7 ++-- .../arm/_passes/fuse_batch_norm2d_pass.py | 21 ++++++++---- .../test_fold_scalar_mul_into_conv_pass.py | 26 ++++++++++++++ .../test/passes/test_fuse_batchnorm_pass.py | 34 ++++++++++++++++++- 4 files changed, 78 insertions(+), 10 deletions(-) diff --git a/backends/arm/_passes/fold_scalar_mul_into_conv_pass.py b/backends/arm/_passes/fold_scalar_mul_into_conv_pass.py index 56df57d3b7a..902565e7d6e 100644 --- a/backends/arm/_passes/fold_scalar_mul_into_conv_pass.py +++ b/backends/arm/_passes/fold_scalar_mul_into_conv_pass.py @@ -249,12 +249,15 @@ def _fold_scale_into_conv( # noqa: C901 else InputKind.PARAMETER ) + # Several convolutions can share one weight and fold different + # scales, so the names must include the multiplication. + folded_suffix = f"_{mul_node.name}_folded_mul" with graph.inserting_before(weight_node): scaled_weight_node = create_constant_placeholder( exp_program=self.exported_program, graph=graph, kind=weight_kind, - name=f"{weight_node.name}_folded_mul", + name=weight_node.name + folded_suffix, data=scaled_weight, ) scaled_bias_node = None @@ -265,7 +268,7 @@ def _fold_scale_into_conv( # noqa: C901 exp_program=self.exported_program, graph=graph, kind=bias_kind, - name=f"{bias_node.name}_folded_mul", + name=bias_node.name + folded_suffix, data=scaled_bias, ) diff --git a/backends/arm/_passes/fuse_batch_norm2d_pass.py b/backends/arm/_passes/fuse_batch_norm2d_pass.py index 69cd52c40b9..c56386e7449 100644 --- a/backends/arm/_passes/fuse_batch_norm2d_pass.py +++ b/backends/arm/_passes/fuse_batch_norm2d_pass.py @@ -42,13 +42,15 @@ def __init__(self, exported_program: ExportedProgram, *args, **kwargs): super().__init__(*args, **kwargs) self.exported_program = exported_program - def get_bias_name(self, weight_node: Node, bias_node: Node | None) -> str: + def get_bias_name( + self, weight_node: Node, bias_node: Node | None, suffix: str + ) -> str: if bias_node: - return bias_node.name + "_fused_bn" + return bias_node.name + suffix elif "weight" in weight_node.name: - return weight_node.name.replace("weight", "bias") + "_fused_bn" + return weight_node.name.replace("weight", "bias") + suffix else: - return weight_node.name + "_bias_fused_bn" + return weight_node.name + "_bias" + suffix @staticmethod def _fuse_grouped_transposed_conv_bn_weights( @@ -281,13 +283,16 @@ def call(self, graph_module: torch.fx.GraphModule) -> PassResult: # noqa: C901 transpose=transposed, ) - # Create fused weights and bias to conv and replace conv args + # Create fused weights and bias to conv and replace conv args. + # Several convolutions can share one weight and fold different + # batch norms, so the names must include the batch norm. + fused_suffix = f"_{node.name}_fused_bn" with graph_module.graph.inserting_before(input_weight_node): fused_conv_weight_node = create_constant_placeholder( exp_program=self.exported_program, graph=graph_module.graph, kind=InputKind.PARAMETER, - name=input_weight_node.name + "_fused_bn", + name=input_weight_node.name + fused_suffix, data=fused_weight, ) @@ -296,7 +301,9 @@ def call(self, graph_module: torch.fx.GraphModule) -> PassResult: # noqa: C901 exp_program=self.exported_program, graph=graph_module.graph, kind=InputKind.PARAMETER, - name=self.get_bias_name(input_weight_node, input_bias_node), + name=self.get_bias_name( + input_weight_node, input_bias_node, fused_suffix + ), data=fused_bias, ) else: diff --git a/backends/arm/test/passes/test_fold_scalar_mul_into_conv_pass.py b/backends/arm/test/passes/test_fold_scalar_mul_into_conv_pass.py index 2a30e38b520..1779e2d4407 100644 --- a/backends/arm/test/passes/test_fold_scalar_mul_into_conv_pass.py +++ b/backends/arm/test/passes/test_fold_scalar_mul_into_conv_pass.py @@ -13,6 +13,7 @@ from executorch.backends.arm._passes.fold_scalar_mul_into_conv_pass import ( FoldScalarMulIntoConvPass, ) +from executorch.backends.arm.test.tester.test_pipeline import TosaPipelineFP from executorch.exir import EdgeCompileConfig, to_edge from executorch.exir.dialects._ops import ops as exir_ops @@ -89,6 +90,17 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: return y * 0.25 + y +class SharedConvTwoScalarMuls(torch.nn.Module): + def __init__(self): + super().__init__() + self.conv = torch.nn.Conv2d(3, 4, 3, padding=1) + + def forward( + self, x: torch.Tensor, y: torch.Tensor + ) -> Tuple[torch.Tensor, torch.Tensor]: + return 0.25 * self.conv(x), 3.0 * self.conv(y) + + class ConvMulWidthScale(torch.nn.Module): def __init__(self): super().__init__() @@ -169,6 +181,20 @@ def test_fold_scalar_mul_into_conv() -> None: torch.testing.assert_close(module(*inputs), transformed.module()(*inputs)) +def test_fold_scalar_mul_into_conv_tosa_FP_shared_conv() -> None: + """One Conv2d is called twice and each call is multiplied by its own scalar, + so the two folds share a weight but each needs its own scaled weight and + bias. + """ + pipeline = TosaPipelineFP[Tuple[torch.Tensor, torch.Tensor]]( + SharedConvTwoScalarMuls().eval(), + (torch.randn(1, 3, 8, 8), torch.randn(1, 3, 8, 8)), + aten_op=[], + ) + pipeline.count_tosa_ops({"CONV2D": 2, "MUL": 0}) + pipeline.run() + + def test_does_not_fold_1d_width_scale_as_channel_scale() -> None: module = ConvMulWidthScale().eval() inputs = (torch.randn(2, 3, 4, 4),) diff --git a/backends/arm/test/passes/test_fuse_batchnorm_pass.py b/backends/arm/test/passes/test_fuse_batchnorm_pass.py index 4b09cdaa647..44a87650019 100644 --- a/backends/arm/test/passes/test_fuse_batchnorm_pass.py +++ b/backends/arm/test/passes/test_fuse_batchnorm_pass.py @@ -8,7 +8,10 @@ import torch from executorch.backends.arm._passes.fuse_batch_norm2d_pass import FuseBatchNorm2dPass from executorch.backends.arm.test import common -from executorch.backends.arm.test.tester.test_pipeline import PassPipeline +from executorch.backends.arm.test.tester.test_pipeline import ( + PassPipeline, + TosaPipelineFP, +) input_t = Tuple[torch.Tensor] # Input x @@ -189,6 +192,22 @@ def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: return z, a +class SharedConvTwoBN(torch.nn.Module): + def __init__(self) -> None: + super().__init__() + self.conv2d = torch.nn.Conv2d(3, 4, kernel_size=3, padding=1) + self.batch_norm2d_1 = torch.nn.BatchNorm2d(4) + self.batch_norm2d_2 = torch.nn.BatchNorm2d(4) + for batch_norm in (self.batch_norm2d_1, self.batch_norm2d_2): + batch_norm.running_mean = torch.rand(4) * 2 - 1 + batch_norm.running_var = torch.rand(4) + 0.5 + + def forward( + self, x: torch.Tensor, y: torch.Tensor + ) -> Tuple[torch.Tensor, torch.Tensor]: + return self.batch_norm2d_1(self.conv2d(x)), self.batch_norm2d_2(self.conv2d(y)) + + modules: Dict[str, ModuleWithBatchNormAttrs] = { "merge_one_of_two_bn_affine": cast(ModuleWithBatchNormAttrs, MergeOneOfTwoBN(True)), "merge_one_of_two_bn": cast(ModuleWithBatchNormAttrs, MergeOneOfTwoBN(False)), @@ -245,3 +264,16 @@ def test_fuse_batch_norm2d_tosa_FP_bf16_skips_fusion() -> None: tosa_extensions=["bf16"], ) pipeline.run() + + +def test_fuse_batch_norm2d_tosa_FP_shared_conv() -> None: + """One Conv2d is called twice and each call feeds its own batch norm, so the + two folds share a weight but each needs its own fused weight and bias. + """ + pipeline = TosaPipelineFP[Tuple[torch.Tensor, torch.Tensor]]( + SharedConvTwoBN().eval(), + (torch.randn(1, 3, 8, 8), torch.randn(1, 3, 8, 8)), + aten_op=[], + ) + pipeline.count_tosa_ops({"CONV2D": 2, "MUL": 0, "SUB": 0}) + pipeline.run() From a8051e26a627d4273f387bdde2aadf5845f9fe5f Mon Sep 17 00:00:00 2001 From: Vaclav Novak Date: Wed, 4 Nov 2026 15:41:05 +0100 Subject: [PATCH 049/128] NXP backend: refactor dataset_creators to enable better benchmarking (#23886) ### Summary Refactor dataset creators of NXP backend to make the benchmarking code more maintainable. With that, I also implemented changes to make sharing the results of benchmarks better to NXP's internal testers. ### Test plan tests can be manually run using `pytest -c /dev/null backends/nxp/tests/` cc @robert-kalmar @JakeStevens @digantdesai @rascani @MartinPavella --- backends/nxp/tests/calibration_dataset.py | 44 +- backends/nxp/tests/dataset_creator.py | 432 ------------------ .../nxp/tests/dataset_creators/__init__.py | 4 + .../dataset_creators/base_dataset_creator.py | 135 ++++++ .../tests/dataset_creators/quant_helpers.py | 81 ++++ .../dataset_creators/real_dataset_creators.py | 98 ++++ .../dataset_creators/synthetic_creators.py | 93 ++++ ...add_batch_size_for_3d_input_pool_2d_ops.py | 4 +- .../generic_tests/test_convert_div_to_mul.py | 4 +- .../test_convert_scalar_to_attr.py | 4 +- .../test_adaptive_avg_pool2d_converter.py | 4 +- .../test_add_tensor_converter.py | 4 +- .../node_converter/test_addmm_converter.py | 4 +- .../node_converter/test_amax_converter.py | 4 +- .../node_converter/test_amin_converter.py | 4 +- .../node_converter/test_bmm_converter.py | 4 +- .../node_converter/test_conv_converter.py | 4 +- .../node_converter/test_exp_converter.py | 2 +- .../test_hardswish_converter.py | 4 +- .../node_converter/test_hardtanh_converter.py | 4 +- .../test_leaky_relu_converter.py | 4 +- .../node_converter/test_log_converter.py | 2 +- .../node_converter/test_maximum_converter.py | 4 +- .../node_converter/test_mean_dim_converter.py | 4 +- .../node_converter/test_minimum_converter.py | 4 +- .../node_converter/test_mm_converter.py | 4 +- .../test_mul_tensor_converter.py | 4 +- .../node_converter/test_neg_converter.py | 2 +- .../node_converter/test_prelu_converter.py | 4 +- .../node_converter/test_relu_converter.py | 4 +- .../node_converter/test_rsqrt_converter.py | 4 +- .../node_converter/test_sigmoid_converter.py | 4 +- .../test_slice_copy_tensor_converter.py | 4 +- .../test_sub_tensor_converter.py | 4 +- .../test_sum_dim_int_list_converter.py | 4 +- .../node_converter/test_tanh_converter.py | 4 +- .../test_upsample_bilinear2d.py | 4 +- .../node_converter/test_upsample_nearest2d.py | 4 +- .../test_view_copy_converter.py | 4 +- .../test_convert_reshaping_nodes_to_view.py | 4 +- backends/nxp/tests/models/test_cifarnet.py | 4 +- .../test_mlperf_tiny_anomaly_detection.py | 2 +- .../test_mlperf_tiny_image_classification.py | 2 +- .../test_mlperf_tiny_keyword_spotting.py | 2 +- .../test_mlperf_tiny_visual_wake_words.py | 2 +- .../nxp/tests/models/test_mobilenet_v2.py | 2 +- .../nxp/tests/models/test_mobilenet_v3.py | 2 +- backends/nxp/tests/nsys_testing.py | 8 +- 48 files changed, 557 insertions(+), 480 deletions(-) delete mode 100644 backends/nxp/tests/dataset_creator.py create mode 100644 backends/nxp/tests/dataset_creators/__init__.py create mode 100644 backends/nxp/tests/dataset_creators/base_dataset_creator.py create mode 100644 backends/nxp/tests/dataset_creators/quant_helpers.py create mode 100644 backends/nxp/tests/dataset_creators/real_dataset_creators.py create mode 100644 backends/nxp/tests/dataset_creators/synthetic_creators.py diff --git a/backends/nxp/tests/calibration_dataset.py b/backends/nxp/tests/calibration_dataset.py index 823cbb02a60..e7a269a16cb 100644 --- a/backends/nxp/tests/calibration_dataset.py +++ b/backends/nxp/tests/calibration_dataset.py @@ -6,13 +6,14 @@ import lzma import pickle +import numpy as np import torch from torch.utils.data.dataset import Dataset class CalibrationDataset(Dataset): - def __init__(self, data_path): + def __init__(self, data_path: str): if data_path.endswith(".xz"): with lzma.open(data_path) as f: self.examples = pickle.load(f) @@ -23,10 +24,41 @@ def __init__(self, data_path): else: raise ValueError("Invalid file format, supported formats are .xz, .pt.") - def __len__(self): + # Ensure all data items are torch.Tensor regardless of source format. + self.examples = [self._to_tensor_example(ex) for ex in self.examples] + + @staticmethod + def _to_tensor(data) -> torch.Tensor: + if isinstance(data, torch.Tensor): + return data + if isinstance(data, np.ndarray): + return torch.from_numpy(data) + raise TypeError(f"Expected Tensor or ndarray, got {type(data).__name__}.") + + @classmethod + def _to_tensor_example(cls, example: tuple) -> tuple: + if not isinstance(example, tuple): + raise NotImplementedError("Examples other than tuple are not supported.") + if len(example) == 2: + data, label = example + elif len(example) == 1: + data = example[0] + label = None + else: + raise ValueError( + f"Unexpected number of elements in example: {len(example)}." + ) + + if isinstance(data, (torch.Tensor, np.ndarray)): + return cls._to_tensor(data), label + if isinstance(data, list): + return [cls._to_tensor(d) for d in data], label + raise TypeError(f"Unsupported data type in example: {type(data).__name__}.") + + def __len__(self) -> int: return len(self.examples) - def __getitem__(self, i): + def __getitem__(self, i: int) -> tuple: return self.examples[i] @@ -34,7 +66,7 @@ class RandomCalibrationDataset(Dataset): def __init__( self, num_examples: int, - sample_shape, + sample_shape: tuple[int, ...] | list[int], num_classes: int, balanced: bool = True, ): @@ -70,8 +102,8 @@ def _create_labels( return labels.tolist() - def __len__(self): + def __len__(self) -> int: return len(self.examples) - def __getitem__(self, i): + def __getitem__(self, i: int) -> tuple[torch.Tensor, int]: return self.examples[i] diff --git a/backends/nxp/tests/dataset_creator.py b/backends/nxp/tests/dataset_creator.py deleted file mode 100644 index cb7f1ba60d5..00000000000 --- a/backends/nxp/tests/dataset_creator.py +++ /dev/null @@ -1,432 +0,0 @@ -# Copyright 2025-2026 NXP -# -# This source code is licensed under the BSD-style license found in the -# LICENSE file in the root directory of this source tree. - -import abc -import os.path -import shutil -from collections import OrderedDict -from copy import deepcopy -from dataclasses import dataclass -from os import mkdir -from random import sample, seed - -import numpy as np -import torch -from executorch.backends.nxp.backend.ir.converter.conversion import translator -from executorch.backends.nxp.backend.ir.converter.conversion.translator import ( - torch_type_to_numpy_type, -) -from executorch.backends.nxp.tests.calibration_dataset import CalibrationDataset -from executorch.backends.nxp.tests.executorch_pipeline import ModelInputSpec -from executorch.exir.scalar_type import ScalarType -from torch import Tensor - - -def _get_calibration_and_testing_dataset_directory_names( - dataset_dir_name: str, -) -> tuple[str, str]: - """Return the names of the directories which contain calibration data for quantization, and testing data for the - inference. The difference is that the testing data may contain channels last samples. - """ - mkdir(calibration_path := os.path.join(dataset_dir_name, "calibration")) - mkdir(test_path := os.path.join(dataset_dir_name, "test")) - return calibration_path, test_path - - -@dataclass -class InputQuantizationSpec: - name: str - scale: float - zp: int - dtype: ScalarType - - -def _replace_input_binary_tensor_with_quantized_variant( - input_bin_tensor_path: str, - input_spec: ModelInputSpec, - q_params: InputQuantizationSpec, -): - tensor = np.fromfile( - input_bin_tensor_path, dtype=torch_type_to_numpy_type(input_spec.dtype) - ) - if q_params.dtype == ScalarType.CHAR: - tensor = np.add(np.round(np.divide(tensor, [q_params.scale])), [q_params.zp]) - tensor = np.clip(tensor, -128, 127).astype(np.int8) - else: - raise ValueError(f"Unknown quantization type: '{q_params.dtype}.") - tensor.tofile(input_bin_tensor_path) - - -def create_quantized_variant_of_dataset( - dataset_dir: str, - dataset_dir_quant: str, - input_quant_spec: list[InputQuantizationSpec], - input_spec: list[ModelInputSpec], -): - """ - Create quantized dataset from provided quantization spec. Dataset is cloned from directory 'dataset_dir'. - - :param dataset_dir: Original (float) dataset directory. - :param dataset_dir_quant: Quantized dataset directory. - :param input_quant_spec: Quantization parameters used for dataset quantization. - :param input_spec: Model inputs specification. - """ - assert len(input_quant_spec) > 0 - - shutil.copytree(dataset_dir, dataset_dir_quant, dirs_exist_ok=True) - - if len(input_quant_spec) == 1: - # Single input dataset - quantize only files in dataset's root dir with first input_quant_spec - input_spec = input_spec[0] - input_quant_spec = input_quant_spec[0] - - for file in os.listdir(dataset_dir_quant): - input_bin_tensor_path = os.path.join(dataset_dir_quant, file) - _replace_input_binary_tensor_with_quantized_variant( - input_bin_tensor_path, input_spec, input_quant_spec - ) - else: - # Iterate over samples (subfolders) - for dir_ in os.listdir(dataset_dir_quant): - # Iterate over each input in sample - sample_dir = os.path.join(dataset_dir_quant, dir_) - - for idx, input_ in enumerate(sorted(os.listdir(sample_dir))): - _replace_input_binary_tensor_with_quantized_variant( - os.path.join(sample_dir, input_), - input_spec[idx], - input_quant_spec[idx], - ) - - -class DatasetCreator(abc.ABC): - - @abc.abstractmethod - def generate_samples( - self, dataset_dir, input_spec: list[ModelInputSpec] - ) -> tuple[str, str]: - pass - - -class RandomDatasetCreator(DatasetCreator): - """Dataset creator that generates random input samples.""" - - def __init__(self, num_samples=2, low=0.0, high=1.0): - self._num_samples = num_samples - self.low = low - self.high = high - - def generate_samples( - self, dataset_dir: str, input_spec: list[ModelInputSpec] - ) -> tuple[str, str]: - assert isinstance(input_spec, list) and all( - isinstance(spec, ModelInputSpec) for spec in input_spec - ), "Input_spec must be a list of ModelInputSpec." - - calibration_dir, test_dir = ( - _get_calibration_and_testing_dataset_directory_names(dataset_dir) - ) - - rng_seed = 42 - if any(spec.dim_order == torch.channels_last for spec in input_spec): - # We will need to generate a separate testing dataset, containing the same data as is in the calibration - # dataset, just permuted to channels last where necessary. - self._gen_samples(test_dir, input_spec, rng_seed) - - else: - # Use the calibration dataset for testing as well. - test_dir = calibration_dir - - # Make sure the calibration dataset contains contiguous tensors. - contiguous_input_spec = deepcopy(input_spec) - for spec in contiguous_input_spec: - spec.dim_order = torch.contiguous_format - - # Generate the calibration dataset. Use the same rng seed as for the testing dataset, to make sure they contain - # the same data (except for the permutation). - self._gen_samples(calibration_dir, contiguous_input_spec, rng_seed) - - return calibration_dir, test_dir - - def _gen_samples( - self, dataset_dir: str, input_spec: list[ModelInputSpec], rng_seed: int - ): - rng = np.random.default_rng(rng_seed) - for idx in range(self._num_samples): - sample_dir = dataset_dir - - # Multi-input, use a subdirectory containing the inputs for each sample - if len(input_spec) > 1: - sample_dir = os.path.join(dataset_dir, f"{str(idx).zfill(4)}") - mkdir(sample_dir) - - for spec_idx, spec in enumerate(input_spec): - match spec.dim_order: - case torch.contiguous_format: - shape = spec.shape - case torch.channels_last: - shape = tuple( - translator.dims_to_channels_last(list(spec.shape)) - ) - case _: - raise ValueError(f"Unsupported dim_order: {spec.dim_order}") - - sample_vector = ( - rng.uniform(self.low, self.high, size=np.prod(shape)) - .astype(torch_type_to_numpy_type(spec.dtype)) - .reshape(shape) - ) - file_name = ( - f"{str(spec_idx).zfill(2)}.bin" - if len(input_spec) > 1 - else f"{str(idx).zfill(4)}.bin" - ) - sample_vector.tofile(os.path.join(sample_dir, file_name)) - - -class CopyDatasetCreator(DatasetCreator): - """Creator that just copies data from other directory.""" - - def __init__(self, source_dir: str): - self._source_dir = source_dir - - def generate_samples(self, dataset_dir, input_spec) -> tuple[str, str]: - assert ( - len(input_spec) == 1 - ), "Only one input is supported for `CopyDatasetCreator` right now." - - calibration_dataset_dir, testing_dataset_dir = ( - _get_calibration_and_testing_dataset_directory_names(dataset_dir) - ) - if input_spec[0].dim_order != torch.channels_last: - # Use the calibration dataset for testing as well. - testing_dataset_dir = calibration_dataset_dir - - for sample_name in os.listdir(self._source_dir): - sample_path = os.path.join(self._source_dir, sample_name) - - # Store the sample in the calibration dataset. - shutil.copy(sample_path, calibration_dataset_dir) - - if input_spec[0].dim_order == torch.channels_last: - # Permute the sample to channels last and store it in the testing dataset. - tensor = np.fromfile( - sample_path, dtype=torch_type_to_numpy_type(input_spec[0].dtype) - ).reshape(input_spec[0].shape) - - if ( - list(tensor.shape) == list(input_spec[0].shape) - and len(tensor.shape) == 4 - ): - # 4D tensor. - tensor = np.moveaxis(tensor, 1, -1) - else: - raise ValueError( - f"Cannot permute a tensor of shape {tensor.shape} to channels last." - ) - - tensor.tofile(os.path.join(testing_dataset_dir, sample_name)) - - return calibration_dataset_dir, testing_dataset_dir - - -class FromCalibrationDataDatasetCreator(DatasetCreator): - """Creator that uses CalibrationDataset archive file.""" - - def __init__( - self, - dataset: CalibrationDataset, - num_examples: int, - idx_to_label: dict[int, str], - ): - self._dataset = dataset - self._num_examples = num_examples - self._idx_to_label = idx_to_label - seed(42) - - @staticmethod - def _get_example_np_data(example, input_spec): - if isinstance(example, tuple): - if len(example) == 2: - data, _ = example - elif len(example) == 1: - data = example[0] - else: - raise ValueError(f"Unexpected number of elements in {example}.") - else: - raise NotImplementedError("Examples other than tuple are not supported.") - - if isinstance(data, Tensor): - return [ - ( - data.numpy() - if data.shape == input_spec[0].shape - else data.unsqueeze(0).numpy() - ) - ] - elif isinstance(data, list) and all(isinstance(dt, Tensor) for dt in data): - return [ - dt.numpy() if dt.shape == ispec.shape else dt.unsqueeze(0).numpy() - for dt, ispec in zip(data, input_spec) - ] - else: - raise TypeError("Data must be a single Tensor or a list of Tensors.") - - def generate_samples(self, dataset_dir, input_spec) -> tuple[str, str]: - os.makedirs(dataset_dir, exist_ok=True) - assert ( - type(self._dataset[0]) is tuple and len(self._dataset[0]) == 2 - ), "Provide calibration data with examples and labels" - - # We need to use ordered collection for deterministic selection of samples - classes = OrderedDict([(cl, None) for _, cl in self._dataset]) - - # If num_examples is less than number of classes, sample only from first num_examples classes - examples_per_class = ( - self._num_examples // len(classes) - if self._num_examples >= len(classes) - else 1 - ) - idx_list = [] - for cl in list(classes.keys())[: self._num_examples]: - cl_idx_list = [ - idx for idx in range(len(self._dataset)) if self._dataset[idx][1] == cl - ] - class_indices = list( - zip(sample(cl_idx_list, examples_per_class), [cl] * examples_per_class) - ) - idx_list.extend(class_indices) - - assert ( - isinstance(input_spec, list) and len(input_spec) == 1 - ) # Other cases are not implemented yet. - - calibration_dir, test_dir = ( - _get_calibration_and_testing_dataset_directory_names(dataset_dir) - ) - - if any(spec.dim_order == torch.channels_last for spec in input_spec): - # We will need to generate a separate testing dataset, containing the same data as is in the calibration - # dataset, just permuted to channels last where necessary. - self._gen_samples(test_dir, input_spec, idx_list) - - else: - # Use the calibration dataset for testing as well. - test_dir = calibration_dir - - # Generate the calibration dataset. Make sure it contains only `contiguous` tensors. - contiguous_input_spec = deepcopy(input_spec) - for spec in contiguous_input_spec: - spec.dim_order = torch.contiguous_format - self._gen_samples(calibration_dir, contiguous_input_spec, idx_list) - - return calibration_dir, test_dir - - def _gen_samples( - self, dataset_dir: str, input_spec: list[ModelInputSpec], idx_list - ): - for i, (idx, cl) in enumerate(idx_list): - label = self._idx_to_label[cl] - example = self._dataset[idx] - data = self._get_example_np_data(example, input_spec) - for inp_idx, dt in enumerate(data): - if input_spec[0].dim_order == torch.channels_last: - if ( - list(dt.shape) == list(input_spec[0].shape) - and len(dt.shape) == 4 - ): - # 4D tensor. - dt = np.moveaxis(dt, 1, -1) - elif ( - list(dt.shape)[1:] == list(input_spec[0].shape) - and len(dt.shape) == 5 - ): - # Multiple 4D tensors. - dt = np.asarray([np.moveaxis(d, 1, -1) for d in dt]) - else: - raise ValueError( - f"Cannot permute a tensor of shape {dt.shape} to channels last." - ) - - bin_file_name = f"{dataset_dir}/example_{label}_{cl}_{i}_i{str(inp_idx).zfill(2)}.bin" - dt.tofile(bin_file_name) - - -class LinearRampDatasetCreator(DatasetCreator): - """Dataset creator that generates deterministic linear ramp input samples. - - The generated data forms a monotonic sequence where values are evenly - distributed between a specified range (low to high) and span the full - interval. The first element is equal to `low` and the last element is - equal to `high`, with increments depending on the total number of elements. - """ - - def __init__(self, num_samples=2, low=-1.0, high=1.0): - self._num_samples = num_samples - self.low = low - self.high = high - - def generate_samples( - self, dataset_dir: str, input_spec: list[ModelInputSpec] - ) -> tuple[str, str]: - assert isinstance(input_spec, list) and all( - isinstance(spec, ModelInputSpec) for spec in input_spec - ), "Input_spec must be a list of ModelInputSpec." - - calibration_dir, test_dir = ( - _get_calibration_and_testing_dataset_directory_names(dataset_dir) - ) - - if any(spec.dim_order == torch.channels_last for spec in input_spec): - # We will need to generate a separate testing dataset, containing the same data as is in the calibration - # dataset, just permuted to channels last where necessary. - self._gen_samples(test_dir, input_spec) - - else: - # Use the calibration dataset for testing as well. - test_dir = calibration_dir - - # Make sure the calibration dataset contains contiguous tensors. - contiguous_input_spec = deepcopy(input_spec) - for spec in contiguous_input_spec: - spec.dim_order = torch.contiguous_format - - # Generate the calibration dataset. Calibration amd testing dataset s will contain - # the same data (except for the permutation). - self._gen_samples(calibration_dir, contiguous_input_spec) - - return calibration_dir, test_dir - - def _gen_samples(self, dataset_dir: str, input_spec: list[ModelInputSpec]): - for idx in range(self._num_samples): - sample_dir = dataset_dir - - # Multi-input, use a subdirectory containing the inputs for each sample - if len(input_spec) > 1: - sample_dir = os.path.join(dataset_dir, f"{str(idx).zfill(4)}") - mkdir(sample_dir) - - for spec_idx, spec in enumerate(input_spec): - match spec.dim_order: - case torch.contiguous_format: - shape = spec.shape - case torch.channels_last: - shape = tuple( - translator.dims_to_channels_last(list(spec.shape)) - ) - case _: - raise ValueError(f"Unsupported dim_order: {spec.dim_order}") - - sample_vector = ( - np.linspace(self.low, self.high, num=np.prod(shape)) - .astype(torch_type_to_numpy_type(spec.dtype)) - .reshape(shape) - ) - file_name = ( - f"{str(spec_idx).zfill(2)}.bin" - if len(input_spec) > 1 - else f"{str(idx).zfill(4)}.bin" - ) - sample_vector.tofile(os.path.join(sample_dir, file_name)) diff --git a/backends/nxp/tests/dataset_creators/__init__.py b/backends/nxp/tests/dataset_creators/__init__.py new file mode 100644 index 00000000000..55dc5fccf45 --- /dev/null +++ b/backends/nxp/tests/dataset_creators/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 NXP +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. diff --git a/backends/nxp/tests/dataset_creators/base_dataset_creator.py b/backends/nxp/tests/dataset_creators/base_dataset_creator.py new file mode 100644 index 00000000000..4255b0be3f7 --- /dev/null +++ b/backends/nxp/tests/dataset_creators/base_dataset_creator.py @@ -0,0 +1,135 @@ +# Copyright 2025-2026 NXP +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import os.path +import shutil +from abc import ABC, abstractmethod +from os import mkdir +from pathlib import Path +from random import seed + +import numpy as np +import torch +from executorch.backends.nxp.backend.ir.converter.conversion.translator import ( + torch_type_to_numpy_type, +) +from executorch.backends.nxp.tests.executorch_pipeline import ModelInputSpec + + +_RNG_SEED = 42 + + +class BaseDatasetCreator(ABC): + """Abstract base class for all dataset creators.""" + + def __init__(self): + self._seed = _RNG_SEED + seed(self._seed) + np.random.seed(self._seed) + torch.manual_seed(self._seed) + + @staticmethod + def _create_calibration_and_test_dataset_dirs( + dataset_dir_path: str | Path, + ) -> tuple[str, str]: + """Create 'calibration' and 'test' subdirectories inside dataset_dir_path and return their paths.""" + mkdir(calibration_dir_path := os.path.join(dataset_dir_path, "calibration")) + mkdir(test_dir_path := os.path.join(dataset_dir_path, "test")) + return calibration_dir_path, test_dir_path + + @staticmethod + def _permute_chl_first_to_chl_last( + tensor: np.ndarray, expected_shape: list[int] | tuple[int, ...] + ) -> np.ndarray: + """Permute a channels-first/contiguous tensor to channels-last layout.""" + if tensor.shape != tuple(expected_shape): + raise ValueError( + f"Cannot permute a tensor of shape {tensor.shape} to channels last." + ) + return np.moveaxis(tensor, 1, -1) + + @staticmethod + def _derive_test_from_calibration( + calibration_dir_path: str, + test_dir_path: str, + input_spec: list[ModelInputSpec], + ) -> None: + """Populate test_dir_path by copying files from calibration_dir_path, permuting to NHWC where channels_last is requested.""" + + def _copy(src: str, dst: str, spec: ModelInputSpec) -> None: + if spec.dim_order == torch.channels_last: + tensor = np.fromfile( + src, dtype=torch_type_to_numpy_type(spec.dtype) + ).reshape(spec.shape) + BaseDatasetCreator._permute_chl_first_to_chl_last( + tensor, spec.shape + ).tofile(dst) + else: + shutil.copy(src, dst) + + if len(input_spec) == 1: + spec = input_spec[0] + for file_name in os.listdir(calibration_dir_path): + src = os.path.join(calibration_dir_path, file_name) + dst = os.path.join(test_dir_path, file_name) + + _copy(src, dst, spec) + else: + for example_dir_name in sorted(os.listdir(calibration_dir_path)): + src_example_dir_path = os.path.join( + calibration_dir_path, example_dir_name + ) + dst_example_dir_path = os.path.join(test_dir_path, example_dir_name) + mkdir(dst_example_dir_path) + + for spec_idx, spec in enumerate(input_spec): + file_name = f"{str(spec_idx).zfill(2)}.bin" + src = os.path.join(src_example_dir_path, file_name) + dst = os.path.join(dst_example_dir_path, file_name) + + _copy(src, dst, spec) + + def generate_samples( + self, output_dir_path: str | Path, input_spec: list[ModelInputSpec] + ) -> tuple[str, str]: + """Generate calibration and test datasets under output_dir_path. + + Creates two subdirectories ('calibration' and 'test') inside output_dir_path. + The calibration directory is populated by create_calibration_dataset and always + contains contiguous (NCHW) tensors. The test directory is derived from it by + permuting tensors to the dim_order requested in input_spec where applicable. + + :param output_dir_path: Root directory under which 'calibration' and 'test' subdirectories are created. + :param input_spec: List of ModelInputSpec describing shape, dtype, and dim_order of each model input. + + :returns: Tuple (calibration_dir_path, test_dir_path). + """ + calibration_dir_path, test_dir_path = ( + self._create_calibration_and_test_dataset_dirs(output_dir_path) + ) + + self.create_calibration_dataset(calibration_dir_path, input_spec) + + # Copy calibration data to test dir, converting to the requested dim_order where needed. + self._derive_test_from_calibration( + calibration_dir_path, test_dir_path, input_spec + ) + + return calibration_dir_path, test_dir_path + + @abstractmethod + def create_calibration_dataset( + self, calibration_dir_path: str | Path, input_spec: list[ModelInputSpec] + ) -> None: + """Populate calibration_dir_path with binary sample files for the given input_spec. + + Implementations must write one .bin file per sample input in contiguous (NCHW) + memory order. For multi-input models each sample must be placed in a zero-padded + numbered subdirectory (e.g. '0000/'), with one file per input named '.bin'. + + :param calibration_dir_path: Directory to write the calibration sample files into. + :param input_spec: List of ModelInputSpec describing shape, dtype, and dim_order of each model input. + """ + pass diff --git a/backends/nxp/tests/dataset_creators/quant_helpers.py b/backends/nxp/tests/dataset_creators/quant_helpers.py new file mode 100644 index 00000000000..52998d7e2ba --- /dev/null +++ b/backends/nxp/tests/dataset_creators/quant_helpers.py @@ -0,0 +1,81 @@ +# Copyright 2025-2026 NXP +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import os.path +import shutil +from dataclasses import dataclass + +import numpy as np +from executorch.backends.nxp.backend.ir.converter.conversion.translator import ( + torch_type_to_numpy_type, +) +from executorch.backends.nxp.tests.executorch_pipeline import ModelInputSpec +from executorch.exir.scalar_type import ScalarType + + +@dataclass +class InputQuantizationSpec: + name: str + scale: float + zp: int + dtype: ScalarType + + +def _replace_input_binary_tensor_with_quantized_variant( + input_bin_tensor_path: str, + input_spec: ModelInputSpec, + q_params: InputQuantizationSpec, +) -> None: + tensor = np.fromfile( + input_bin_tensor_path, dtype=torch_type_to_numpy_type(input_spec.dtype) + ) + if q_params.dtype == ScalarType.CHAR: + tensor = np.add(np.round(np.divide(tensor, [q_params.scale])), [q_params.zp]) + tensor = np.clip(tensor, -128, 127).astype(np.int8) + else: + raise ValueError(f"Unknown quantization type: '{q_params.dtype}'.") + tensor.tofile(input_bin_tensor_path) + + +def create_quantized_variant_of_dataset( + dataset_dir: str, + dataset_dir_quant: str, + input_quant_spec: list[InputQuantizationSpec], + input_spec: list[ModelInputSpec], +) -> None: + """ + Create quantized dataset from provided quantization spec. Dataset is cloned from directory 'dataset_dir'. + + :param dataset_dir: Original (float) dataset directory. + :param dataset_dir_quant: Quantized dataset directory. + :param input_quant_spec: Quantization parameters used for dataset quantization. + :param input_spec: Model inputs specification. + """ + assert len(input_quant_spec) > 0 + + shutil.copytree(dataset_dir, dataset_dir_quant, dirs_exist_ok=True) + + if len(input_quant_spec) == 1: + # Single input dataset - quantize only files in dataset's root dir with first input_quant_spec + input_spec = input_spec[0] + input_quant_spec = input_quant_spec[0] + + for file in os.listdir(dataset_dir_quant): + input_bin_tensor_path = os.path.join(dataset_dir_quant, file) + _replace_input_binary_tensor_with_quantized_variant( + input_bin_tensor_path, input_spec, input_quant_spec + ) + else: + # Iterate over samples (subfolders) + for dir_ in os.listdir(dataset_dir_quant): + # Iterate over each input in sample + sample_dir = os.path.join(dataset_dir_quant, dir_) + + for idx, input_ in enumerate(sorted(os.listdir(sample_dir))): + _replace_input_binary_tensor_with_quantized_variant( + os.path.join(sample_dir, input_), + input_spec[idx], + input_quant_spec[idx], + ) diff --git a/backends/nxp/tests/dataset_creators/real_dataset_creators.py b/backends/nxp/tests/dataset_creators/real_dataset_creators.py new file mode 100644 index 00000000000..4d8b877da73 --- /dev/null +++ b/backends/nxp/tests/dataset_creators/real_dataset_creators.py @@ -0,0 +1,98 @@ +# Copyright 2025-2026 NXP +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import os.path +import shutil +from pathlib import Path +from random import sample + +from executorch.backends.nxp.tests.calibration_dataset import CalibrationDataset +from executorch.backends.nxp.tests.dataset_creators.base_dataset_creator import ( + BaseDatasetCreator, +) + +from executorch.backends.nxp.tests.executorch_pipeline import ModelInputSpec + + +class CopyDatasetCreator(BaseDatasetCreator): + """Creator that just copies data from other directory.""" + + def __init__(self, source_dir: str): + super().__init__() + self._source_dir = source_dir + + def create_calibration_dataset( + self, calibration_dir_path: str | Path, input_spec: list[ModelInputSpec] + ) -> None: + # Copy all source files into the calibration directory (always contiguous). + for example_name in os.listdir(self._source_dir): + shutil.copy( + os.path.join(self._source_dir, example_name), calibration_dir_path + ) + + +class FromCalibrationDataDatasetCreator(BaseDatasetCreator): + """Creator that uses CalibrationDataset archive file.""" + + def __init__( + self, + dataset: CalibrationDataset, + num_examples: int, + idx_to_label: dict[int, str], + ): + super().__init__() + self._dataset = dataset + assert ( + type(self._dataset[0]) is tuple and len(self._dataset[0]) == 2 + ), "Provide calibration data with examples and labels" + + self._num_examples = num_examples + self._idx_to_label = idx_to_label + + def create_calibration_dataset( + self, calibration_dir_path: str | Path, input_spec: list[ModelInputSpec] + ) -> None: + assert ( + isinstance(input_spec, list) and len(input_spec) == 1 + ), "Only one input is supported right now." + + examples_to_classes = self._sample_examples() + self._write_examples(calibration_dir_path, examples_to_classes) + + def _sample_examples(self) -> list[tuple[int, int]]: + """Sample up to num_examples (dataset_index, class_id) pairs, balanced across all classes.""" + # Use ordered collection for deterministic selection of samples + unique_classes = dict.fromkeys(cl for _, cl in self._dataset) + + # If num_examples is less than number of classes, sample only from first num_examples classes + examples_per_class = ( + self._num_examples // len(unique_classes) + if self._num_examples >= len(unique_classes) + else 1 + ) + examples_to_classes: list[tuple[int, int]] = [] + for cl in list(unique_classes.keys())[: self._num_examples]: + cl_idx_list = [ + idx for idx in range(len(self._dataset)) if self._dataset[idx][1] == cl + ] + examples_to_classes.extend( + (idx, cl) for idx in sample(cl_idx_list, examples_per_class) + ) + return examples_to_classes + + def _write_examples( + self, + dataset_dir_path: str, + examples_to_classes: list[tuple[int, int]], + ) -> None: + """Write binary .bin sample files for each (dataset_index, class_id) pair to dataset_dir_path.""" + for i, (idx, cl) in enumerate(examples_to_classes): + label = self._idx_to_label[cl] + example, _ = self._dataset[idx] + + example_tensors = example if isinstance(example, list) else [example] + for spec_idx, tensor in enumerate(example_tensors): + bin_file_name = f"{dataset_dir_path}/example_{label}_{cl}_{i}_i{str(spec_idx).zfill(2)}.bin" + tensor.numpy().tofile(bin_file_name) diff --git a/backends/nxp/tests/dataset_creators/synthetic_creators.py b/backends/nxp/tests/dataset_creators/synthetic_creators.py new file mode 100644 index 00000000000..95eb83a7817 --- /dev/null +++ b/backends/nxp/tests/dataset_creators/synthetic_creators.py @@ -0,0 +1,93 @@ +# Copyright 2025-2026 NXP +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import os.path +from abc import abstractmethod +from os import mkdir +from pathlib import Path + +import numpy as np +from executorch.backends.nxp.backend.ir.converter.conversion.translator import ( + torch_type_to_numpy_type, +) +from executorch.backends.nxp.tests.dataset_creators.base_dataset_creator import ( + BaseDatasetCreator, +) +from executorch.backends.nxp.tests.executorch_pipeline import ModelInputSpec + + +class SyntheticDatasetCreator(BaseDatasetCreator): + """Base class for dataset creators that synthesize example data from scratch.""" + + def __init__(self, num_examples: int = 2): + super().__init__() + self._num_examples = num_examples + + @abstractmethod + def generate_examples(self, shape: tuple) -> np.ndarray: + """Generate an array of artificial values with the requested shape + to be used as the dataset examples. + + :param shape: Shape of the output array. + + :returns: numpy array of generated values with the given shape. + """ + pass + + def create_calibration_dataset( + self, calibration_dir_path: str | Path, input_spec: list[ModelInputSpec] + ) -> None: + for idx in range(self._num_examples): + example_dir_path = calibration_dir_path + + # Multi-input: use a subdirectory containing the inputs for each example + if len(input_spec) > 1: + example_dir_path = os.path.join( + calibration_dir_path, f"{str(idx).zfill(4)}" + ) + mkdir(example_dir_path) + + for spec_idx, spec in enumerate(input_spec): + # calibration data is always stored in contiguous (NCHW) format + shape = tuple(spec.shape) + example_vector = self.generate_examples(shape).astype( + torch_type_to_numpy_type(spec.dtype) + ) + file_name = ( + f"{str(spec_idx).zfill(2)}.bin" + if len(input_spec) > 1 + else f"{str(idx).zfill(4)}.bin" + ) + example_vector.tofile(os.path.join(example_dir_path, file_name)) + + +class RandomDatasetCreator(SyntheticDatasetCreator): + """Dataset creator that generates data from uniform distribution.""" + + def __init__(self, num_examples: int = 2, low: float = 0.0, high: float = 1.0): + super().__init__(num_examples) + self.low = low + self.high = high + self._rng = np.random.default_rng(self._seed) + + def generate_examples(self, shape: tuple) -> np.ndarray: + return self._rng.uniform(self.low, self.high, size=shape) + + +class LinearRampDatasetCreator(SyntheticDatasetCreator): + """Dataset creator that generates deterministic linear ramp input examples. + + The generated data forms a monotonic sequence where values are evenly + distributed between a specified range (low to high) and span the full + interval. + """ + + def __init__(self, num_examples: int = 2, low: float = -1.0, high: float = 1.0): + super().__init__(num_examples) + self.low = low + self.high = high + + def generate_examples(self, shape: tuple) -> np.ndarray: + return np.linspace(self.low, self.high, num=int(np.prod(shape))).reshape(shape) diff --git a/backends/nxp/tests/generic_tests/test_add_batch_size_for_3d_input_pool_2d_ops.py b/backends/nxp/tests/generic_tests/test_add_batch_size_for_3d_input_pool_2d_ops.py index a3eae135144..fb935387add 100644 --- a/backends/nxp/tests/generic_tests/test_add_batch_size_for_3d_input_pool_2d_ops.py +++ b/backends/nxp/tests/generic_tests/test_add_batch_size_for_3d_input_pool_2d_ops.py @@ -22,7 +22,9 @@ MaxPool2DWithIndices, ViewCopy, ) -from executorch.backends.nxp.tests.dataset_creator import RandomDatasetCreator +from executorch.backends.nxp.tests.dataset_creators.synthetic_creators import ( + RandomDatasetCreator, +) from executorch.backends.nxp.tests.executorch_pipeline import neutron_target_spec from executorch.backends.nxp.tests.executors import graph_contains_any_of_ops from executorch.backends.nxp.tests.graph_verifier import DetailedGraphVerifier diff --git a/backends/nxp/tests/generic_tests/test_convert_div_to_mul.py b/backends/nxp/tests/generic_tests/test_convert_div_to_mul.py index 89879226c3a..7083f1f3471 100644 --- a/backends/nxp/tests/generic_tests/test_convert_div_to_mul.py +++ b/backends/nxp/tests/generic_tests/test_convert_div_to_mul.py @@ -14,7 +14,9 @@ NeutronAtenPassManager, ) from executorch.backends.nxp.backend.ops_aliases import MulTensor -from executorch.backends.nxp.tests.dataset_creator import RandomDatasetCreator +from executorch.backends.nxp.tests.dataset_creators.synthetic_creators import ( + RandomDatasetCreator, +) from executorch.backends.nxp.tests.executorch_pipeline import neutron_target_spec from executorch.backends.nxp.tests.executors import graph_contains_any_of_ops from executorch.backends.nxp.tests.graph_verifier import DetailedGraphVerifier diff --git a/backends/nxp/tests/generic_tests/test_convert_scalar_to_attr.py b/backends/nxp/tests/generic_tests/test_convert_scalar_to_attr.py index be214b77568..e169dc7b084 100644 --- a/backends/nxp/tests/generic_tests/test_convert_scalar_to_attr.py +++ b/backends/nxp/tests/generic_tests/test_convert_scalar_to_attr.py @@ -19,7 +19,9 @@ try_get_tensor_constant_from_node, ) from executorch.backends.nxp.backend.ops_aliases import AddTensor, MulTensor, SubTensor -from executorch.backends.nxp.tests.dataset_creator import RandomDatasetCreator +from executorch.backends.nxp.tests.dataset_creators.synthetic_creators import ( + RandomDatasetCreator, +) from executorch.backends.nxp.tests.executorch_pipeline import neutron_target_spec from executorch.backends.nxp.tests.executors import graph_contains_any_of_ops from executorch.backends.nxp.tests.graph_verifier import DetailedGraphVerifier diff --git a/backends/nxp/tests/ir/converter/node_converter/test_adaptive_avg_pool2d_converter.py b/backends/nxp/tests/ir/converter/node_converter/test_adaptive_avg_pool2d_converter.py index 75093aba530..fd78cb1ff04 100644 --- a/backends/nxp/tests/ir/converter/node_converter/test_adaptive_avg_pool2d_converter.py +++ b/backends/nxp/tests/ir/converter/node_converter/test_adaptive_avg_pool2d_converter.py @@ -14,7 +14,9 @@ ViewCopy, ) -from executorch.backends.nxp.tests.dataset_creator import RandomDatasetCreator +from executorch.backends.nxp.tests.dataset_creators.synthetic_creators import ( + RandomDatasetCreator, +) from executorch.backends.nxp.tests.executorch_pipeline import to_quantized_edge_program from executorch.backends.nxp.tests.executors import graph_contains_any_of_ops from executorch.backends.nxp.tests.graph_verifier import DetailedGraphVerifier diff --git a/backends/nxp/tests/ir/converter/node_converter/test_add_tensor_converter.py b/backends/nxp/tests/ir/converter/node_converter/test_add_tensor_converter.py index 5c3b0dd0100..98881ad5398 100644 --- a/backends/nxp/tests/ir/converter/node_converter/test_add_tensor_converter.py +++ b/backends/nxp/tests/ir/converter/node_converter/test_add_tensor_converter.py @@ -15,7 +15,9 @@ MaxPool2DWithIndices, ) -from executorch.backends.nxp.tests.dataset_creator import RandomDatasetCreator +from executorch.backends.nxp.tests.dataset_creators.synthetic_creators import ( + RandomDatasetCreator, +) from executorch.backends.nxp.tests.executorch_pipeline import ( ModelInputSpec, to_quantized_edge_program, diff --git a/backends/nxp/tests/ir/converter/node_converter/test_addmm_converter.py b/backends/nxp/tests/ir/converter/node_converter/test_addmm_converter.py index 713a618ea53..7a313dfd7c3 100644 --- a/backends/nxp/tests/ir/converter/node_converter/test_addmm_converter.py +++ b/backends/nxp/tests/ir/converter/node_converter/test_addmm_converter.py @@ -16,7 +16,9 @@ ViewCopy, ) -from executorch.backends.nxp.tests.dataset_creator import RandomDatasetCreator +from executorch.backends.nxp.tests.dataset_creators.synthetic_creators import ( + RandomDatasetCreator, +) from executorch.backends.nxp.tests.executorch_pipeline import to_quantized_edge_program from executorch.backends.nxp.tests.executors import graph_contains_any_of_ops from executorch.backends.nxp.tests.graph_verifier import DetailedGraphVerifier, Operator diff --git a/backends/nxp/tests/ir/converter/node_converter/test_amax_converter.py b/backends/nxp/tests/ir/converter/node_converter/test_amax_converter.py index 892ea70018e..ef8c2ffb8e3 100644 --- a/backends/nxp/tests/ir/converter/node_converter/test_amax_converter.py +++ b/backends/nxp/tests/ir/converter/node_converter/test_amax_converter.py @@ -28,7 +28,9 @@ GetItem, MaxPool2DWithIndices, ) -from executorch.backends.nxp.tests.dataset_creator import RandomDatasetCreator +from executorch.backends.nxp.tests.dataset_creators.synthetic_creators import ( + RandomDatasetCreator, +) from executorch.backends.nxp.tests.executorch_pipeline import to_quantized_edge_program from executorch.backends.nxp.tests.executors import graph_contains_any_of_ops from executorch.backends.nxp.tests.graph_verifier import DetailedGraphVerifier diff --git a/backends/nxp/tests/ir/converter/node_converter/test_amin_converter.py b/backends/nxp/tests/ir/converter/node_converter/test_amin_converter.py index 3ae13bafe40..5eb67351f4b 100644 --- a/backends/nxp/tests/ir/converter/node_converter/test_amin_converter.py +++ b/backends/nxp/tests/ir/converter/node_converter/test_amin_converter.py @@ -28,7 +28,9 @@ GetItem, MaxPool2DWithIndices, ) -from executorch.backends.nxp.tests.dataset_creator import RandomDatasetCreator +from executorch.backends.nxp.tests.dataset_creators.synthetic_creators import ( + RandomDatasetCreator, +) from executorch.backends.nxp.tests.executorch_pipeline import to_quantized_edge_program from executorch.backends.nxp.tests.executors import graph_contains_any_of_ops from executorch.backends.nxp.tests.graph_verifier import DetailedGraphVerifier diff --git a/backends/nxp/tests/ir/converter/node_converter/test_bmm_converter.py b/backends/nxp/tests/ir/converter/node_converter/test_bmm_converter.py index 5bb7a080b23..170b5bbec20 100644 --- a/backends/nxp/tests/ir/converter/node_converter/test_bmm_converter.py +++ b/backends/nxp/tests/ir/converter/node_converter/test_bmm_converter.py @@ -15,7 +15,9 @@ from executorch.backends.nxp.edge_passes.move_auxiliary_operator_into_separate_qdq_cluster_pass import ( ViewCopy, ) -from executorch.backends.nxp.tests.dataset_creator import RandomDatasetCreator +from executorch.backends.nxp.tests.dataset_creators.synthetic_creators import ( + RandomDatasetCreator, +) from executorch.backends.nxp.tests.executorch_pipeline import ModelInputSpec from executorch.backends.nxp.tests.graph_verifier import DetailedGraphVerifier from executorch.backends.nxp.tests.model_output_comparator import ( diff --git a/backends/nxp/tests/ir/converter/node_converter/test_conv_converter.py b/backends/nxp/tests/ir/converter/node_converter/test_conv_converter.py index fb982f4503d..871965ad56a 100644 --- a/backends/nxp/tests/ir/converter/node_converter/test_conv_converter.py +++ b/backends/nxp/tests/ir/converter/node_converter/test_conv_converter.py @@ -13,7 +13,9 @@ ExecutorchDelegateCall, ViewCopy, ) -from executorch.backends.nxp.tests.dataset_creator import RandomDatasetCreator +from executorch.backends.nxp.tests.dataset_creators.synthetic_creators import ( + RandomDatasetCreator, +) from executorch.backends.nxp.tests.executorch_pipeline import to_quantized_edge_program from executorch.backends.nxp.tests.executors import graph_contains_any_of_ops from executorch.backends.nxp.tests.graph_verifier import DetailedGraphVerifier diff --git a/backends/nxp/tests/ir/converter/node_converter/test_exp_converter.py b/backends/nxp/tests/ir/converter/node_converter/test_exp_converter.py index 9f8068f67d4..dda8317bd73 100644 --- a/backends/nxp/tests/ir/converter/node_converter/test_exp_converter.py +++ b/backends/nxp/tests/ir/converter/node_converter/test_exp_converter.py @@ -13,7 +13,7 @@ from executorch.backends.nxp.tests.graph_verifier import DetailedGraphVerifier from executorch.backends.nxp.tests.nsys_testing import lower_run_compare from executorch.backends.nxp.tests.use_qat import * # noqa F403 -from executorch.backends.nxp.tests.dataset_creator import ( +from executorch.backends.nxp.tests.dataset_creators.synthetic_creators import ( LinearRampDatasetCreator, RandomDatasetCreator, ) diff --git a/backends/nxp/tests/ir/converter/node_converter/test_hardswish_converter.py b/backends/nxp/tests/ir/converter/node_converter/test_hardswish_converter.py index e0f626a2140..13c11a15411 100644 --- a/backends/nxp/tests/ir/converter/node_converter/test_hardswish_converter.py +++ b/backends/nxp/tests/ir/converter/node_converter/test_hardswish_converter.py @@ -16,7 +16,9 @@ ViewCopy, ) -from executorch.backends.nxp.tests.dataset_creator import RandomDatasetCreator +from executorch.backends.nxp.tests.dataset_creators.synthetic_creators import ( + RandomDatasetCreator, +) from executorch.backends.nxp.tests.graph_verifier import DetailedGraphVerifier from executorch.backends.nxp.tests.model_output_comparator import ( AllCloseOutputComparator, diff --git a/backends/nxp/tests/ir/converter/node_converter/test_hardtanh_converter.py b/backends/nxp/tests/ir/converter/node_converter/test_hardtanh_converter.py index dc71f5df921..883c0e37ac5 100644 --- a/backends/nxp/tests/ir/converter/node_converter/test_hardtanh_converter.py +++ b/backends/nxp/tests/ir/converter/node_converter/test_hardtanh_converter.py @@ -23,7 +23,9 @@ ExecutorchDelegateCall, HardTanh, ) -from executorch.backends.nxp.tests.dataset_creator import RandomDatasetCreator +from executorch.backends.nxp.tests.dataset_creators.synthetic_creators import ( + RandomDatasetCreator, +) from executorch.backends.nxp.tests.executorch_pipeline import to_quantized_edge_program from executorch.backends.nxp.tests.executors import graph_contains_any_of_ops from executorch.backends.nxp.tests.graph_verifier import DetailedGraphVerifier diff --git a/backends/nxp/tests/ir/converter/node_converter/test_leaky_relu_converter.py b/backends/nxp/tests/ir/converter/node_converter/test_leaky_relu_converter.py index 5176a8c61db..c7d56ccd1a3 100644 --- a/backends/nxp/tests/ir/converter/node_converter/test_leaky_relu_converter.py +++ b/backends/nxp/tests/ir/converter/node_converter/test_leaky_relu_converter.py @@ -10,7 +10,9 @@ import torch from executorch.backends.nxp.backend.ops_aliases import LeakyRelu -from executorch.backends.nxp.tests.dataset_creator import RandomDatasetCreator +from executorch.backends.nxp.tests.dataset_creators.synthetic_creators import ( + RandomDatasetCreator, +) from executorch.backends.nxp.tests.graph_verifier import DetailedGraphVerifier from executorch.backends.nxp.tests.nsys_testing import lower_run_compare from executorch.backends.nxp.tests.use_qat import * # noqa F403 diff --git a/backends/nxp/tests/ir/converter/node_converter/test_log_converter.py b/backends/nxp/tests/ir/converter/node_converter/test_log_converter.py index ebf6a8bfb9b..473ea385965 100644 --- a/backends/nxp/tests/ir/converter/node_converter/test_log_converter.py +++ b/backends/nxp/tests/ir/converter/node_converter/test_log_converter.py @@ -13,7 +13,7 @@ from executorch.backends.nxp.tests.graph_verifier import DetailedGraphVerifier from executorch.backends.nxp.tests.nsys_testing import lower_run_compare from executorch.backends.nxp.tests.use_qat import * # noqa F403 -from executorch.backends.nxp.tests.dataset_creator import ( +from executorch.backends.nxp.tests.dataset_creators.synthetic_creators import ( LinearRampDatasetCreator, RandomDatasetCreator, ) diff --git a/backends/nxp/tests/ir/converter/node_converter/test_maximum_converter.py b/backends/nxp/tests/ir/converter/node_converter/test_maximum_converter.py index 01df6211dfc..2e9f2e6a7e7 100644 --- a/backends/nxp/tests/ir/converter/node_converter/test_maximum_converter.py +++ b/backends/nxp/tests/ir/converter/node_converter/test_maximum_converter.py @@ -15,7 +15,9 @@ MaxPool2DWithIndices, ) -from executorch.backends.nxp.tests.dataset_creator import RandomDatasetCreator +from executorch.backends.nxp.tests.dataset_creators.synthetic_creators import ( + RandomDatasetCreator, +) from executorch.backends.nxp.tests.executorch_pipeline import ( ModelInputSpec, to_quantized_edge_program, diff --git a/backends/nxp/tests/ir/converter/node_converter/test_mean_dim_converter.py b/backends/nxp/tests/ir/converter/node_converter/test_mean_dim_converter.py index 14f55de94d4..4e174786c6a 100644 --- a/backends/nxp/tests/ir/converter/node_converter/test_mean_dim_converter.py +++ b/backends/nxp/tests/ir/converter/node_converter/test_mean_dim_converter.py @@ -28,7 +28,9 @@ MaxPool2DWithIndices, MeanDim, ) -from executorch.backends.nxp.tests.dataset_creator import RandomDatasetCreator +from executorch.backends.nxp.tests.dataset_creators.synthetic_creators import ( + RandomDatasetCreator, +) from executorch.backends.nxp.tests.executorch_pipeline import to_quantized_edge_program from executorch.backends.nxp.tests.executors import graph_contains_any_of_ops from executorch.backends.nxp.tests.graph_verifier import DetailedGraphVerifier diff --git a/backends/nxp/tests/ir/converter/node_converter/test_minimum_converter.py b/backends/nxp/tests/ir/converter/node_converter/test_minimum_converter.py index 727ff914ceb..3bd3d136841 100644 --- a/backends/nxp/tests/ir/converter/node_converter/test_minimum_converter.py +++ b/backends/nxp/tests/ir/converter/node_converter/test_minimum_converter.py @@ -15,7 +15,9 @@ Minimum, ) -from executorch.backends.nxp.tests.dataset_creator import RandomDatasetCreator +from executorch.backends.nxp.tests.dataset_creators.synthetic_creators import ( + RandomDatasetCreator, +) from executorch.backends.nxp.tests.executorch_pipeline import ( ModelInputSpec, to_quantized_edge_program, diff --git a/backends/nxp/tests/ir/converter/node_converter/test_mm_converter.py b/backends/nxp/tests/ir/converter/node_converter/test_mm_converter.py index a8997c11ef5..797805f42de 100644 --- a/backends/nxp/tests/ir/converter/node_converter/test_mm_converter.py +++ b/backends/nxp/tests/ir/converter/node_converter/test_mm_converter.py @@ -10,7 +10,9 @@ import torch from executorch.backends.nxp.backend.ops_aliases import MM, PermuteCopy, ViewCopy -from executorch.backends.nxp.tests.dataset_creator import RandomDatasetCreator +from executorch.backends.nxp.tests.dataset_creators.synthetic_creators import ( + RandomDatasetCreator, +) from executorch.backends.nxp.tests.graph_verifier import DetailedGraphVerifier, Operator from executorch.backends.nxp.tests.nsys_testing import lower_run_compare from executorch.backends.nxp.tests.simple_models import LinearModule, MmModule diff --git a/backends/nxp/tests/ir/converter/node_converter/test_mul_tensor_converter.py b/backends/nxp/tests/ir/converter/node_converter/test_mul_tensor_converter.py index 414cbecad85..4cae38d111e 100644 --- a/backends/nxp/tests/ir/converter/node_converter/test_mul_tensor_converter.py +++ b/backends/nxp/tests/ir/converter/node_converter/test_mul_tensor_converter.py @@ -15,7 +15,9 @@ MulTensor, ) -from executorch.backends.nxp.tests.dataset_creator import RandomDatasetCreator +from executorch.backends.nxp.tests.dataset_creators.synthetic_creators import ( + RandomDatasetCreator, +) from executorch.backends.nxp.tests.executorch_pipeline import ( ModelInputSpec, to_quantized_edge_program, diff --git a/backends/nxp/tests/ir/converter/node_converter/test_neg_converter.py b/backends/nxp/tests/ir/converter/node_converter/test_neg_converter.py index 182e7754013..11076aa79ec 100644 --- a/backends/nxp/tests/ir/converter/node_converter/test_neg_converter.py +++ b/backends/nxp/tests/ir/converter/node_converter/test_neg_converter.py @@ -10,7 +10,7 @@ import torch from executorch.backends.nxp.backend.ops_aliases import Convolution, Neg -from executorch.backends.nxp.tests.dataset_creator import ( +from executorch.backends.nxp.tests.dataset_creators.synthetic_creators import ( LinearRampDatasetCreator, RandomDatasetCreator, ) diff --git a/backends/nxp/tests/ir/converter/node_converter/test_prelu_converter.py b/backends/nxp/tests/ir/converter/node_converter/test_prelu_converter.py index 58a820f1a7f..26cc17cb578 100644 --- a/backends/nxp/tests/ir/converter/node_converter/test_prelu_converter.py +++ b/backends/nxp/tests/ir/converter/node_converter/test_prelu_converter.py @@ -22,7 +22,9 @@ ViewCopy, WhereSelf, ) -from executorch.backends.nxp.tests.dataset_creator import RandomDatasetCreator +from executorch.backends.nxp.tests.dataset_creators.synthetic_creators import ( + RandomDatasetCreator, +) from executorch.backends.nxp.tests.executors import graph_contains_any_of_ops from executorch.backends.nxp.tests.graph_verifier import DetailedGraphVerifier from executorch.backends.nxp.tests.model_output_comparator import ( diff --git a/backends/nxp/tests/ir/converter/node_converter/test_relu_converter.py b/backends/nxp/tests/ir/converter/node_converter/test_relu_converter.py index d44fc1480db..c47b0c1d629 100644 --- a/backends/nxp/tests/ir/converter/node_converter/test_relu_converter.py +++ b/backends/nxp/tests/ir/converter/node_converter/test_relu_converter.py @@ -19,7 +19,9 @@ Relu, ViewCopy, ) -from executorch.backends.nxp.tests.dataset_creator import RandomDatasetCreator +from executorch.backends.nxp.tests.dataset_creators.synthetic_creators import ( + RandomDatasetCreator, +) from executorch.backends.nxp.tests.executorch_pipeline import to_quantized_edge_program from executorch.backends.nxp.tests.executors import graph_contains_any_of_ops from executorch.backends.nxp.tests.graph_verifier import DetailedGraphVerifier diff --git a/backends/nxp/tests/ir/converter/node_converter/test_rsqrt_converter.py b/backends/nxp/tests/ir/converter/node_converter/test_rsqrt_converter.py index 916ab5ba518..fc5bf99576c 100644 --- a/backends/nxp/tests/ir/converter/node_converter/test_rsqrt_converter.py +++ b/backends/nxp/tests/ir/converter/node_converter/test_rsqrt_converter.py @@ -10,7 +10,9 @@ import torch from executorch.backends.nxp.backend.ops_aliases import Rsqrt -from executorch.backends.nxp.tests.dataset_creator import RandomDatasetCreator +from executorch.backends.nxp.tests.dataset_creators.synthetic_creators import ( + RandomDatasetCreator, +) from executorch.backends.nxp.tests.graph_verifier import DetailedGraphVerifier from executorch.backends.nxp.tests.model_output_comparator import ( AllCloseOutputComparator, diff --git a/backends/nxp/tests/ir/converter/node_converter/test_sigmoid_converter.py b/backends/nxp/tests/ir/converter/node_converter/test_sigmoid_converter.py index c6ea90cb5e2..191486803e1 100644 --- a/backends/nxp/tests/ir/converter/node_converter/test_sigmoid_converter.py +++ b/backends/nxp/tests/ir/converter/node_converter/test_sigmoid_converter.py @@ -11,7 +11,9 @@ import torch from executorch.backends.nxp.backend.ops_aliases import Sigmoid -from executorch.backends.nxp.tests.dataset_creator import RandomDatasetCreator +from executorch.backends.nxp.tests.dataset_creators.synthetic_creators import ( + RandomDatasetCreator, +) from executorch.backends.nxp.tests.graph_verifier import DetailedGraphVerifier from executorch.backends.nxp.tests.model_output_comparator import ( AllCloseOutputComparator, diff --git a/backends/nxp/tests/ir/converter/node_converter/test_slice_copy_tensor_converter.py b/backends/nxp/tests/ir/converter/node_converter/test_slice_copy_tensor_converter.py index 00b89c859ef..84d03840b9e 100644 --- a/backends/nxp/tests/ir/converter/node_converter/test_slice_copy_tensor_converter.py +++ b/backends/nxp/tests/ir/converter/node_converter/test_slice_copy_tensor_converter.py @@ -14,7 +14,9 @@ SliceCopy, ) -from executorch.backends.nxp.tests.dataset_creator import RandomDatasetCreator +from executorch.backends.nxp.tests.dataset_creators.synthetic_creators import ( + RandomDatasetCreator, +) from executorch.backends.nxp.tests.executorch_pipeline import to_quantized_edge_program from executorch.backends.nxp.tests.executors import graph_contains_any_of_ops from executorch.backends.nxp.tests.graph_verifier import DetailedGraphVerifier diff --git a/backends/nxp/tests/ir/converter/node_converter/test_sub_tensor_converter.py b/backends/nxp/tests/ir/converter/node_converter/test_sub_tensor_converter.py index 54d0b3c6cc2..7f072e41cc2 100644 --- a/backends/nxp/tests/ir/converter/node_converter/test_sub_tensor_converter.py +++ b/backends/nxp/tests/ir/converter/node_converter/test_sub_tensor_converter.py @@ -15,7 +15,9 @@ SubTensor, ) -from executorch.backends.nxp.tests.dataset_creator import RandomDatasetCreator +from executorch.backends.nxp.tests.dataset_creators.synthetic_creators import ( + RandomDatasetCreator, +) from executorch.backends.nxp.tests.executorch_pipeline import ( ModelInputSpec, to_quantized_edge_program, diff --git a/backends/nxp/tests/ir/converter/node_converter/test_sum_dim_int_list_converter.py b/backends/nxp/tests/ir/converter/node_converter/test_sum_dim_int_list_converter.py index 5726776c78a..426c0dff714 100644 --- a/backends/nxp/tests/ir/converter/node_converter/test_sum_dim_int_list_converter.py +++ b/backends/nxp/tests/ir/converter/node_converter/test_sum_dim_int_list_converter.py @@ -28,7 +28,9 @@ MaxPool2DWithIndices, SumDimIntList, ) -from executorch.backends.nxp.tests.dataset_creator import RandomDatasetCreator +from executorch.backends.nxp.tests.dataset_creators.synthetic_creators import ( + RandomDatasetCreator, +) from executorch.backends.nxp.tests.executorch_pipeline import to_quantized_edge_program from executorch.backends.nxp.tests.executors import graph_contains_any_of_ops from executorch.backends.nxp.tests.graph_verifier import DetailedGraphVerifier diff --git a/backends/nxp/tests/ir/converter/node_converter/test_tanh_converter.py b/backends/nxp/tests/ir/converter/node_converter/test_tanh_converter.py index 71b6ced4ed2..1cd6282565e 100644 --- a/backends/nxp/tests/ir/converter/node_converter/test_tanh_converter.py +++ b/backends/nxp/tests/ir/converter/node_converter/test_tanh_converter.py @@ -9,7 +9,9 @@ import torch from executorch.backends.nxp.backend.ops_aliases import Convolution, Tanh -from executorch.backends.nxp.tests.dataset_creator import RandomDatasetCreator +from executorch.backends.nxp.tests.dataset_creators.synthetic_creators import ( + RandomDatasetCreator, +) from executorch.backends.nxp.tests.graph_verifier import DetailedGraphVerifier from executorch.backends.nxp.tests.nsys_testing import lower_run_compare from executorch.backends.nxp.tests.simple_models import Conv2dWithActivation diff --git a/backends/nxp/tests/ir/converter/node_converter/test_upsample_bilinear2d.py b/backends/nxp/tests/ir/converter/node_converter/test_upsample_bilinear2d.py index 8513b87a1a3..664b28f14d9 100644 --- a/backends/nxp/tests/ir/converter/node_converter/test_upsample_bilinear2d.py +++ b/backends/nxp/tests/ir/converter/node_converter/test_upsample_bilinear2d.py @@ -14,7 +14,9 @@ UpsampleBilinear2D, ) -from executorch.backends.nxp.tests.dataset_creator import RandomDatasetCreator +from executorch.backends.nxp.tests.dataset_creators.synthetic_creators import ( + RandomDatasetCreator, +) from executorch.backends.nxp.tests.executorch_pipeline import to_quantized_edge_program from executorch.backends.nxp.tests.executors import graph_contains_any_of_ops from executorch.backends.nxp.tests.graph_verifier import DetailedGraphVerifier diff --git a/backends/nxp/tests/ir/converter/node_converter/test_upsample_nearest2d.py b/backends/nxp/tests/ir/converter/node_converter/test_upsample_nearest2d.py index dd3d7c9c147..361fcf57922 100644 --- a/backends/nxp/tests/ir/converter/node_converter/test_upsample_nearest2d.py +++ b/backends/nxp/tests/ir/converter/node_converter/test_upsample_nearest2d.py @@ -14,7 +14,9 @@ UpsampleNearest2D, ) -from executorch.backends.nxp.tests.dataset_creator import RandomDatasetCreator +from executorch.backends.nxp.tests.dataset_creators.synthetic_creators import ( + RandomDatasetCreator, +) from executorch.backends.nxp.tests.executorch_pipeline import to_quantized_edge_program from executorch.backends.nxp.tests.executors import graph_contains_any_of_ops from executorch.backends.nxp.tests.graph_verifier import DetailedGraphVerifier diff --git a/backends/nxp/tests/ir/converter/node_converter/test_view_copy_converter.py b/backends/nxp/tests/ir/converter/node_converter/test_view_copy_converter.py index 009e1abc142..63942267817 100644 --- a/backends/nxp/tests/ir/converter/node_converter/test_view_copy_converter.py +++ b/backends/nxp/tests/ir/converter/node_converter/test_view_copy_converter.py @@ -22,7 +22,9 @@ Relu, ViewCopy, ) -from executorch.backends.nxp.tests.dataset_creator import RandomDatasetCreator +from executorch.backends.nxp.tests.dataset_creators.synthetic_creators import ( + RandomDatasetCreator, +) from executorch.backends.nxp.tests.executorch_pipeline import to_quantized_edge_program from executorch.backends.nxp.tests.executors import graph_contains_any_of_ops from executorch.backends.nxp.tests.graph_verifier import DetailedGraphVerifier diff --git a/backends/nxp/tests/ir/edge_passes/test_convert_reshaping_nodes_to_view.py b/backends/nxp/tests/ir/edge_passes/test_convert_reshaping_nodes_to_view.py index 82cc3f63526..f7323cf7495 100644 --- a/backends/nxp/tests/ir/edge_passes/test_convert_reshaping_nodes_to_view.py +++ b/backends/nxp/tests/ir/edge_passes/test_convert_reshaping_nodes_to_view.py @@ -7,7 +7,9 @@ import pytest import torch from executorch.backends.nxp.backend.ops_aliases import AddTensor, ViewCopy -from executorch.backends.nxp.tests.dataset_creator import RandomDatasetCreator +from executorch.backends.nxp.tests.dataset_creators.synthetic_creators import ( + RandomDatasetCreator, +) from executorch.backends.nxp.tests.graph_verifier import DetailedGraphVerifier from executorch.backends.nxp.tests.nsys_testing import ( AllCloseOutputComparator, diff --git a/backends/nxp/tests/models/test_cifarnet.py b/backends/nxp/tests/models/test_cifarnet.py index b623016d412..fe030c41ede 100644 --- a/backends/nxp/tests/models/test_cifarnet.py +++ b/backends/nxp/tests/models/test_cifarnet.py @@ -10,7 +10,9 @@ import torch from executorch.backends.nxp.tests.config_importer import test_config -from executorch.backends.nxp.tests.dataset_creator import CopyDatasetCreator +from executorch.backends.nxp.tests.dataset_creators.real_dataset_creators import ( + CopyDatasetCreator, +) from executorch.backends.nxp.tests.executorch_pipeline import ModelInputSpec from executorch.backends.nxp.tests.graph_verifier import BaseGraphVerifier from executorch.backends.nxp.tests.model_output_comparator import ( diff --git a/backends/nxp/tests/models/test_mlperf_tiny_anomaly_detection.py b/backends/nxp/tests/models/test_mlperf_tiny_anomaly_detection.py index 57e7640cf62..3bdbac58e50 100644 --- a/backends/nxp/tests/models/test_mlperf_tiny_anomaly_detection.py +++ b/backends/nxp/tests/models/test_mlperf_tiny_anomaly_detection.py @@ -9,7 +9,7 @@ import pytest import torch -from executorch.backends.nxp.tests.dataset_creator import ( +from executorch.backends.nxp.tests.dataset_creators.real_dataset_creators import ( FromCalibrationDataDatasetCreator, ) from executorch.backends.nxp.tests.executorch_pipeline import ModelInputSpec diff --git a/backends/nxp/tests/models/test_mlperf_tiny_image_classification.py b/backends/nxp/tests/models/test_mlperf_tiny_image_classification.py index 8a1a462f8a4..e8d2932a249 100644 --- a/backends/nxp/tests/models/test_mlperf_tiny_image_classification.py +++ b/backends/nxp/tests/models/test_mlperf_tiny_image_classification.py @@ -11,7 +11,7 @@ import pytest import torch -from executorch.backends.nxp.tests.dataset_creator import ( +from executorch.backends.nxp.tests.dataset_creators.real_dataset_creators import ( FromCalibrationDataDatasetCreator, ) from executorch.backends.nxp.tests.executorch_pipeline import ModelInputSpec diff --git a/backends/nxp/tests/models/test_mlperf_tiny_keyword_spotting.py b/backends/nxp/tests/models/test_mlperf_tiny_keyword_spotting.py index 6fa48716619..0e802258a6d 100644 --- a/backends/nxp/tests/models/test_mlperf_tiny_keyword_spotting.py +++ b/backends/nxp/tests/models/test_mlperf_tiny_keyword_spotting.py @@ -11,7 +11,7 @@ import pytest import torch -from executorch.backends.nxp.tests.dataset_creator import ( +from executorch.backends.nxp.tests.dataset_creators.real_dataset_creators import ( FromCalibrationDataDatasetCreator, ) from executorch.backends.nxp.tests.executorch_pipeline import ModelInputSpec diff --git a/backends/nxp/tests/models/test_mlperf_tiny_visual_wake_words.py b/backends/nxp/tests/models/test_mlperf_tiny_visual_wake_words.py index 56ba1d155b9..a4ce53f0d30 100644 --- a/backends/nxp/tests/models/test_mlperf_tiny_visual_wake_words.py +++ b/backends/nxp/tests/models/test_mlperf_tiny_visual_wake_words.py @@ -11,7 +11,7 @@ import pytest import torch -from executorch.backends.nxp.tests.dataset_creator import ( +from executorch.backends.nxp.tests.dataset_creators.real_dataset_creators import ( FromCalibrationDataDatasetCreator, ) from executorch.backends.nxp.tests.executorch_pipeline import ModelInputSpec diff --git a/backends/nxp/tests/models/test_mobilenet_v2.py b/backends/nxp/tests/models/test_mobilenet_v2.py index b06d0ccbf1c..8c8321d4c1e 100644 --- a/backends/nxp/tests/models/test_mobilenet_v2.py +++ b/backends/nxp/tests/models/test_mobilenet_v2.py @@ -11,7 +11,7 @@ import pytest import torch -from executorch.backends.nxp.tests.dataset_creator import ( +from executorch.backends.nxp.tests.dataset_creators.real_dataset_creators import ( FromCalibrationDataDatasetCreator, ) from executorch.backends.nxp.tests.executorch_pipeline import ModelInputSpec diff --git a/backends/nxp/tests/models/test_mobilenet_v3.py b/backends/nxp/tests/models/test_mobilenet_v3.py index a981a28d918..e15abb754b9 100644 --- a/backends/nxp/tests/models/test_mobilenet_v3.py +++ b/backends/nxp/tests/models/test_mobilenet_v3.py @@ -11,7 +11,7 @@ import pytest import torch -from executorch.backends.nxp.tests.dataset_creator import ( +from executorch.backends.nxp.tests.dataset_creators.real_dataset_creators import ( FromCalibrationDataDatasetCreator, ) from executorch.backends.nxp.tests.executorch_pipeline import ModelInputSpec diff --git a/backends/nxp/tests/nsys_testing.py b/backends/nxp/tests/nsys_testing.py index 4f7d3a3fa02..f4a5bc079b3 100644 --- a/backends/nxp/tests/nsys_testing.py +++ b/backends/nxp/tests/nsys_testing.py @@ -21,10 +21,14 @@ from executorch.backends.nxp.backend.ops_aliases import ExecutorchDelegateCall from executorch.backends.nxp.neutron_partitioner import NeutronPartitioner from executorch.backends.nxp.tests.config_importer import test_config -from executorch.backends.nxp.tests.dataset_creator import ( - CopyDatasetCreator, +from executorch.backends.nxp.tests.dataset_creators.quant_helpers import ( create_quantized_variant_of_dataset, InputQuantizationSpec, +) +from executorch.backends.nxp.tests.dataset_creators.real_dataset_creators import ( + CopyDatasetCreator, +) +from executorch.backends.nxp.tests.dataset_creators.synthetic_creators import ( RandomDatasetCreator, ) from executorch.backends.nxp.tests.executorch_pipeline import ( From d59c0802a369addb06997ded12f231ea6b180a48 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Wed, 4 Nov 2026 07:02:10 -0800 Subject: [PATCH 050/128] Pin the Hugging Face downloads in the LoRA CI tests (#24590) ### What is broken CI reads Hugging Face models from a shared cache that is mounted read-only. The 2 LoRA jobs in `pull.yml` download `unsloth/Qwen3-0.6B`, the `lucylq/qwen3_06B_lora_math` adapter and, inside export, `Qwen/Qwen3-0.6B`, all from their `main` branch. When a model owner pushes any new commit, even a README edit, `hf download` resolves `main` to it and tries to create a new snapshot folder in the read-only cache: ``` OSError: [Errno 30] Read-only file system: '/mnt/hf_cache/hub/models--.../snapshots/...' ``` That broke `test-torchao-huggingface-checkpoints` on November 3, which #24524 fixed by pinning its downloads. The LoRA jobs have the same problem waiting to happen, and there it would break every pull request. ### The fix Pin every LoRA download to the commit the cache already holds, the same way #24524, `export_model_artifact.sh` and `mlx.yml` pin models: 1. Pin the tokenizer and adapter downloads. `download_hf_hub.sh` gets a `--revision` option for this. 2. Pin the base `Qwen/Qwen3-0.6B` checkpoint in the LoRA exports. The scripts convert the pinned snapshot once with the same converter export uses, and pass it as `base.checkpoint`. With a commit hash, the download finds the cached snapshot and does not try to create a new one. A missing snapshot still fails the job. The tokenizer and adapter pins come from the snapshot paths in recent passing runs. The `Qwen/Qwen3-0.6B` pin is the commit those runs downloaded, and the same commit `export_model_artifact.sh` pins. ### Testing I ran both scripts against a local read-only Hugging Face cache holding the same commits, with the `huggingface_hub` version each job uses. The Python export and build steps were stubbed, so this covers the downloads only. - With the cache holding only older commits, the current scripts fail with the same error as CI, and the same scripts pinned to those older commits pass. - With the cache holding the pinned commits, both LoRA scripts pass, and every export gets the pinned checkpoint. This pull request touches `.ci/scripts`, so pull runs these jobs in full. --- .ci/scripts/download_hf_hub.sh | 22 +++++++++++++++------- .ci/scripts/test_lora.sh | 22 +++++++++++++++++++++- .ci/scripts/test_lora_multimethod.sh | 21 +++++++++++++++++++-- 3 files changed, 55 insertions(+), 10 deletions(-) diff --git a/.ci/scripts/download_hf_hub.sh b/.ci/scripts/download_hf_hub.sh index c0487e687c7..94aa19ce3f1 100644 --- a/.ci/scripts/download_hf_hub.sh +++ b/.ci/scripts/download_hf_hub.sh @@ -7,13 +7,15 @@ export HF_HUB_DISABLE_XET=1 # Arguments: # 1. model_id: The Hugging Face repository ID (e.g., "organization/model_name") # 2. subdir: The optional subdirectory in the repo to look for files (pass "" if not used) -# 3. file_names: A space-separated list of filenames to be downloaded +# 3. revision: The commit hash to download +# 4. file_names: A space-separated list of filenames to be downloaded # Returns: # The directory containing the downloaded files function download_hf_files() { local model_id="$1" local subdir="$2" - shift 2 + local revision="$3" + shift 3 local file_names=("$@") # Capture all remaining arguments as an array local download_dir @@ -24,7 +26,8 @@ from huggingface_hub import hf_hub_download # Download the first file and get its directory path = hf_hub_download( repo_id='${model_id}', - filename='${subdir:+${subdir}/}${file_names[0]}' + filename='${subdir:+${subdir}/}${file_names[0]}', + revision='${revision}' ) import os print(os.path.dirname(path))") @@ -41,7 +44,8 @@ from huggingface_hub import hf_hub_download # Download the file hf_hub_download( repo_id='${model_id}', - filename='${subdir:+${subdir}/}${file_name}' + filename='${subdir:+${subdir}/}${file_name}', + revision='${revision}' )" if [ $? -ne 0 ]; then @@ -67,6 +71,10 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then SUBDIR="$2" shift 2 ;; + --revision) + REVISION="$2" + shift 2 + ;; --files) shift FILES_TO_DOWNLOAD=() @@ -83,13 +91,13 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then done # Validate required arguments - if [ -z "$MODEL_ID" ] || [ ${#FILES_TO_DOWNLOAD[@]} -eq 0 ]; then - echo "Usage: $0 --model_id --subdir --files [ ...]" >&2 + if [ -z "$MODEL_ID" ] || [ -z "$REVISION" ] || [ ${#FILES_TO_DOWNLOAD[@]} -eq 0 ]; then + echo "Usage: $0 --model_id --revision --subdir --files [ ...]" >&2 exit 1 fi # Call the function - DOWNLOAD_DIR=$(download_hf_files "$MODEL_ID" "$SUBDIR" "${FILES_TO_DOWNLOAD[@]}") + DOWNLOAD_DIR=$(download_hf_files "$MODEL_ID" "$SUBDIR" "$REVISION" "${FILES_TO_DOWNLOAD[@]}") if [ $? -eq 0 ]; then echo "$DOWNLOAD_DIR" else diff --git a/.ci/scripts/test_lora.sh b/.ci/scripts/test_lora.sh index 91c78ae5f3e..2adb80e8bdf 100644 --- a/.ci/scripts/test_lora.sh +++ b/.ci/scripts/test_lora.sh @@ -57,11 +57,17 @@ PY } # Hosting lora adapter in personal repo for now. +# The Hugging Face downloads below are pinned to commits the read-only +# CI cache holds. To bump a pin, seed the cache first from a pytorch/executorch +# branch (forks cannot write the cache): run pull.yml on it with +# workflow_dispatch, or push to it after its pull request has the +# ci-refresh-hf-cache label. python -m pip install -q huggingface_hub HF_ADAPTER_REPO="lucylq/qwen3_06B_lora_math" HF_ADAPTER_PATH=$( bash "$(dirname "${BASH_SOURCE[0]}")/download_hf_hub.sh" \ --model_id "${HF_ADAPTER_REPO}" \ + --revision 0e6bc3a88a597c5abc4872734eec8db3a9fdd846 \ --files "adapter_config.json" "adapter_model.safetensors" ) @@ -69,14 +75,25 @@ HF_ADAPTER_PATH=$( export LORA_ADAPTER_CHECKPOINT="${HF_ADAPTER_PATH}/adapter_model.safetensors" export LORA_ADAPTER_CONFIG="${HF_ADAPTER_PATH}/adapter_config.json" +# Without a checkpoint, export downloads Qwen/Qwen3-0.6B from its main branch, +# so convert the pinned commit the read-only CI cache holds instead. +QWEN_SNAPSHOT=$($PYTHON_EXECUTABLE -m executorch.examples.models.llama.hf_download \ + snapshot Qwen/Qwen3-0.6B --revision c1899de289a04d12100db370d81485cdf75e47ca) +QWEN_CHECKPOINT_DIR=$(mktemp -d "${RUNNER_TEMP:-/tmp}/qwen3_0_6b_XXXXXX") +trap 'rm -rf "${QWEN_CHECKPOINT_DIR}"' EXIT +QWEN_CHECKPOINT="${QWEN_CHECKPOINT_DIR}/qwen3_0_6b.pth" +$PYTHON_EXECUTABLE -m executorch.examples.models.qwen3.convert_weights \ + "${QWEN_SNAPSHOT}" "${QWEN_CHECKPOINT}" + ### SINGLE LORA PTE ### # Export LoRA PTE file. $PYTHON_EXECUTABLE -m extension.llm.export.export_llm \ --config examples/models/qwen3/config/qwen3_xnnpack_lora.yaml \ + +base.checkpoint="${QWEN_CHECKPOINT}" \ +export.output_name="qwen_lora_math_full.pte" # Capture the path of the downloaded qwen artifacts -HF_QWEN_PATH=$(python -c "from huggingface_hub import snapshot_download; print(snapshot_download('unsloth/Qwen3-0.6B'))") +HF_QWEN_PATH=$(python -c "from huggingface_hub import snapshot_download; print(snapshot_download('unsloth/Qwen3-0.6B', revision='db4e90b8359f816f09bf5c944797ce116f26da3c'))") echo "Model downloaded to: $HF_QWEN_PATH" ### BUILD LLAMA RUNNER. @@ -120,6 +137,7 @@ fi # Export LoRA PTE, LoRA PTD, foundation PTD file. $PYTHON_EXECUTABLE -m extension.llm.export.export_llm \ --config examples/models/qwen3/config/qwen3_xnnpack_lora.yaml \ + +base.checkpoint="${QWEN_CHECKPOINT}" \ +export.output_name="qwen_lora_math.pte" \ +export.foundation_weights_file="qwen_foundation.ptd" \ +export.lora_weights_file="qwen_lora_math.ptd" @@ -179,6 +197,7 @@ The answer is: 12<|im_end|>" # and loading lora weights. $PYTHON_EXECUTABLE -m extension.llm.export.export_llm \ --config examples/models/qwen3/config/qwen3_xnnpack_lora.yaml \ + +base.checkpoint="${QWEN_CHECKPOINT}" \ base.lora_config=null \ +export.output_name="qwen_q.pte" \ +export.foundation_weights_file="qwen_foundation_q.ptd" \ @@ -188,6 +207,7 @@ $PYTHON_EXECUTABLE -m extension.llm.export.export_llm \ # Export Quantized LoRA PTE, LoRA PTD, foundation PTD file. $PYTHON_EXECUTABLE -m extension.llm.export.export_llm \ --config examples/models/qwen3/config/qwen3_xnnpack_lora.yaml \ + +base.checkpoint="${QWEN_CHECKPOINT}" \ +export.output_name="qwen_lora_math_q.pte" \ +export.foundation_weights_file="qwen_foundation_lora_q.ptd" \ +export.lora_weights_file="qwen_lora_math_q.ptd" \ diff --git a/.ci/scripts/test_lora_multimethod.sh b/.ci/scripts/test_lora_multimethod.sh index afcde517cd6..811d847a255 100755 --- a/.ci/scripts/test_lora_multimethod.sh +++ b/.ci/scripts/test_lora_multimethod.sh @@ -57,25 +57,42 @@ PY } # Download LoRA adapter. +# The Hugging Face downloads below are pinned to commits the read-only +# CI cache holds. To bump a pin, seed the cache first from a pytorch/executorch +# branch (forks cannot write the cache): run pull.yml on it with +# workflow_dispatch, or push to it after its pull request has the +# ci-refresh-hf-cache label. python -m pip install -q huggingface_hub HF_ADAPTER_REPO="lucylq/qwen3_06B_lora_math" HF_ADAPTER_PATH=$( bash "$(dirname "${BASH_SOURCE[0]}")/download_hf_hub.sh" \ --model_id "${HF_ADAPTER_REPO}" \ + --revision 0e6bc3a88a597c5abc4872734eec8db3a9fdd846 \ --files "adapter_config.json" "adapter_model.safetensors" ) # Download base model (for tokenizer path). -HF_QWEN_PATH=$(python -c "from huggingface_hub import snapshot_download; print(snapshot_download('unsloth/Qwen3-0.6B'))") +HF_QWEN_PATH=$(python -c "from huggingface_hub import snapshot_download; print(snapshot_download('unsloth/Qwen3-0.6B', revision='db4e90b8359f816f09bf5c944797ce116f26da3c'))") echo "Model downloaded to: $HF_QWEN_PATH" +# Without a checkpoint, export downloads Qwen/Qwen3-0.6B from its main branch, +# so convert the pinned commit the read-only CI cache holds instead. +QWEN_SNAPSHOT=$($PYTHON_EXECUTABLE -m executorch.examples.models.llama.hf_download \ + snapshot Qwen/Qwen3-0.6B --revision c1899de289a04d12100db370d81485cdf75e47ca) +QWEN_CHECKPOINT_DIR=$(mktemp -d "${RUNNER_TEMP:-/tmp}/qwen3_0_6b_XXXXXX") +trap 'rm -rf "${QWEN_CHECKPOINT_DIR}"' EXIT +QWEN_CHECKPOINT="${QWEN_CHECKPOINT_DIR}/qwen3_0_6b.pth" +$PYTHON_EXECUTABLE -m executorch.examples.models.qwen3.convert_weights \ + "${QWEN_SNAPSHOT}" "${QWEN_CHECKPOINT}" + ### EXPORT MULTIMETHOD PTE ### # Set environment variables for OmegaConf interpolation in yaml. export LORA_ADAPTER_CHECKPOINT="${HF_ADAPTER_PATH}/adapter_model.safetensors" export LORA_ADAPTER_CONFIG="${HF_ADAPTER_PATH}/adapter_config.json" $PYTHON_EXECUTABLE -m extension.llm.export.export_llm \ - --config examples/models/qwen3/config/qwen3_multimethod.yaml + --config examples/models/qwen3/config/qwen3_multimethod.yaml \ + +base.checkpoint="${QWEN_CHECKPOINT}" ### BUILD LLAMA RUNNER ### cmake_install_executorch_libraries From 0061fd29914b9fdbc5b275b165f15db8e82bd844 Mon Sep 17 00:00:00 2001 From: Erik Lundell Date: Wed, 4 Nov 2026 16:55:13 +0100 Subject: [PATCH 051/128] Arm backend: Add API to configure Ethos-U PMU-counters (#24612) `set_ethosu_pmu_events` takes an array of ethosu_pmu_event_type and sets for both Ethos-U55/65, And Ethos-U85. Since U55/65 only allows four counters, ignore entries above four. `set_ethosu_pmu_events_for_device` additionally allows the user to set the list per device type. cc @digantdesai @freddan80 @per @zingo @oscarandersson8218 @mansnils @Sebastian-Larsson @robell @rascani Signed-off-by: Erik Lundell --- backends/arm/runtime/EthosUExecutionStats.cpp | 91 +++++++++---- backends/arm/runtime/EthosUExecutionStats.h | 19 ++- backends/arm/runtime/targets.bzl | 7 +- .../arm/test/ethosu_pmu_callbacks_test.cpp | 121 ++++++++++++++++++ backends/arm/test/ethosu_pmu_test_stubs.cpp | 6 +- 5 files changed, 214 insertions(+), 30 deletions(-) diff --git a/backends/arm/runtime/EthosUExecutionStats.cpp b/backends/arm/runtime/EthosUExecutionStats.cpp index 7c51d1762eb..a5c646436df 100644 --- a/backends/arm/runtime/EthosUExecutionStats.cpp +++ b/backends/arm/runtime/EthosUExecutionStats.cpp @@ -6,6 +6,7 @@ */ #include +#include #include #include @@ -32,6 +33,34 @@ void write_le64(uint8_t* destination, uint64_t value) { namespace executorch::backends::arm { namespace { std::atomic capture_buffer{nullptr}; + +std::array u55_u65_events = { + ETHOSU_PMU_AXI0_RD_DATA_BEAT_RECEIVED, + ETHOSU_PMU_AXI1_RD_DATA_BEAT_RECEIVED, + ETHOSU_PMU_AXI0_WR_DATA_BEAT_WRITTEN, + ETHOSU_PMU_NPU_IDLE, +}; + +std::array u85_events = { + ETHOSU_PMU_SRAM_RD_DATA_BEAT_RECEIVED, + ETHOSU_PMU_SRAM_WR_DATA_BEAT_WRITTEN, + ETHOSU_PMU_EXT_RD_DATA_BEAT_RECEIVED, + ETHOSU_PMU_EXT_WR_DATA_BEAT_WRITTEN, + ETHOSU_PMU_NPU_IDLE, + ETHOSU_PMU_MAC_ACTIVE, + ETHOSU_PMU_WD_ACTIVE, + ETHOSU_PMU_NO_EVENT, +}; + +template +void copy_pmu_events( + std::array& destination, + executorch::runtime::ArrayRef events) { + destination.fill(ETHOSU_PMU_NO_EVENT); + if (!events.empty()) { + std::copy_n(events.begin(), events.size(), destination.begin()); + } +} } // namespace EthosUStatsBuffer* set_ethosu_stats_buffer(EthosUStatsBuffer* buffer) noexcept { @@ -42,6 +71,26 @@ bool ethosu_execution_metrics_enabled() noexcept { return true; } +executorch::runtime::Error set_ethosu55_65_pmu_events( + executorch::runtime::ArrayRef events) noexcept { + using executorch::runtime::Error; + if (events.size() > u55_u65_events.size()) { + return Error::InvalidArgument; + } + copy_pmu_events(u55_u65_events, events); + return Error::Ok; +} + +executorch::runtime::Error set_ethosu85_pmu_events( + executorch::runtime::ArrayRef events) noexcept { + using executorch::runtime::Error; + if (events.size() > u85_events.size()) { + return Error::InvalidArgument; + } + copy_pmu_events(u85_events, events); + return Error::Ok; +} + } // namespace executorch::backends::arm using executorch::backends::arm::EthosUInvocationContext; @@ -65,31 +114,15 @@ void ethosu_inference_begin(struct ethosu_driver* drv, void* user_arg) { || invocation->event_tracer != nullptr #endif ) { - static constexpr ethosu_pmu_event_type kU55U65Events[] = { - ETHOSU_PMU_AXI0_RD_DATA_BEAT_RECEIVED, - ETHOSU_PMU_AXI1_RD_DATA_BEAT_RECEIVED, - ETHOSU_PMU_AXI0_WR_DATA_BEAT_WRITTEN, - ETHOSU_PMU_NPU_IDLE, - }; - static constexpr ethosu_pmu_event_type kU85Events[] = { - ETHOSU_PMU_SRAM_RD_DATA_BEAT_RECEIVED, - ETHOSU_PMU_SRAM_WR_DATA_BEAT_WRITTEN, - ETHOSU_PMU_EXT_RD_DATA_BEAT_RECEIVED, - ETHOSU_PMU_EXT_WR_DATA_BEAT_WRITTEN, - ETHOSU_PMU_NPU_IDLE, - ETHOSU_PMU_MAC_ACTIVE, - ETHOSU_PMU_WD_ACTIVE, - }; - const ethosu_pmu_event_type* events = nullptr; uint32_t event_count = 0; if (drv->dev.caps.product == ETHOSU_PRODUCT_U55 || drv->dev.caps.product == ETHOSU_PRODUCT_U65) { - events = kU55U65Events; - event_count = sizeof(kU55U65Events) / sizeof(kU55U65Events[0]); + events = executorch::backends::arm::u55_u65_events.data(); + event_count = executorch::backends::arm::u55_u65_events.size(); } else if (drv->dev.caps.product == ETHOSU_PRODUCT_U85) { - events = kU85Events; - event_count = sizeof(kU85Events) / sizeof(kU85Events[0]); + events = executorch::backends::arm::u85_events.data(); + event_count = executorch::backends::arm::u85_events.size(); } if (events != nullptr) { @@ -100,10 +133,15 @@ void ethosu_inference_begin(struct ethosu_driver* drv, void* user_arg) { ETHOSU_PMU_Enable(drv); ETHOSU_PMU_PMCCNTR_CFG_Set_Stop_Event(drv, ETHOSU_PMU_NPU_IDLE); ETHOSU_PMU_PMCCNTR_CFG_Set_Start_Event(drv, ETHOSU_PMU_NPU_ACTIVE); + uint32_t event_mask = 0; for (uint32_t slot = 0; slot < invocation->counter_count; ++slot) { - ETHOSU_PMU_Set_EVTYPER(drv, slot, events[slot]); + const auto event = events[slot]; + ETHOSU_PMU_Set_EVTYPER(drv, slot, event); + if (event != ETHOSU_PMU_NO_EVENT) { + event_mask |= 1u << slot; + } } - const uint32_t event_mask = (1u << invocation->counter_count) - 1u; + ETHOSU_PMU_CNTR_Disable(drv, (1u << invocation->counter_count) - 1u); ETHOSU_PMU_CNTR_Enable(drv, event_mask | ETHOSU_PMU_CCNT_Msk); ETHOSU_PMU_CYCCNT_Reset(drv); ETHOSU_PMU_EVCNTR_ALL_Reset(drv); @@ -139,10 +177,13 @@ void ethosu_inference_end(struct ethosu_driver* drv, void* user_arg) { record.stats.cycle_count_valid = true; const uint32_t overflow_status = ETHOSU_PMU_Get_CNTR_OVS(drv); record.stats.cycle_overflow = (overflow_status & ETHOSU_PMU_CCNT_Msk) != 0; - record.stats.counter_count = invocation->counter_count; for (uint32_t slot = 0; slot < invocation->counter_count; ++slot) { - record.stats.counters[slot] = { - static_cast(ETHOSU_PMU_Get_EVTYPER(drv, slot)), + const auto event = ETHOSU_PMU_Get_EVTYPER(drv, slot); + if (event == ETHOSU_PMU_NO_EVENT) { + continue; + } + record.stats.counters[record.stats.counter_count++] = { + static_cast(event), ETHOSU_PMU_Get_EVCNTR(drv, slot), (overflow_status & (1u << slot)) != 0}; } diff --git a/backends/arm/runtime/EthosUExecutionStats.h b/backends/arm/runtime/EthosUExecutionStats.h index 4e3c905255c..550ab05b33b 100644 --- a/backends/arm/runtime/EthosUExecutionStats.h +++ b/backends/arm/runtime/EthosUExecutionStats.h @@ -11,6 +11,11 @@ #include #include +#include + +#include +#include + // Optional runner hooks. The stats library provides weak no-op definitions. extern "C" { void EthosUBackend_npu_begin(); @@ -34,6 +39,16 @@ EthosUStatsBuffer* set_ethosu_stats_buffer(EthosUStatsBuffer* buffer) noexcept; // Returns true when this library is linked. bool ethosu_execution_metrics_enabled() noexcept; +// Configure counter slots before inference. U55/U65 share four slots; U85 has +// eight. Short lists are padded with ETHOSU_PMU_NO_EVENT; longer lists are +// rejected. Pass an empty list to disable event counters for that target. +// These functions only configure events; collection requires an event tracer +// or a registered EthosUStatsBuffer. Do not call during inference. +executorch::runtime::Error set_ethosu55_65_pmu_events( + executorch::runtime::ArrayRef events) noexcept; +executorch::runtime::Error set_ethosu85_pmu_events( + executorch::runtime::ArrayRef events) noexcept; + constexpr size_t kMaxPmuEventCounters = 8; constexpr char kEthosUCycleEventName[] = "ethosu.pmu.counters"; @@ -43,8 +58,8 @@ constexpr char kEthosUCycleEventName[] = "ethosu.pmu.counters"; // EthosUPlatform [12:16], flags [16:20] (bit 0: cycle overflow, // bit 1: invocation succeeded), register base [20:28], NPU cycle count // [28:36], and counter count [36:40]. -// Each populated counter then stores -// a symbolic event ID (4 bytes), count (8 bytes), and overflow (4 bytes). +// Each active counter then stores a symbolic event ID (4 bytes), count +// (8 bytes), and overflow (4 bytes). NO_EVENT slots are omitted. constexpr size_t kEthosUCycleMetadataHeaderSize = 40; constexpr size_t kEthosUCycleCounterMetadataSize = 16; constexpr size_t kEthosUCycleMetadataMaxSize = kEthosUCycleMetadataHeaderSize + diff --git a/backends/arm/runtime/targets.bzl b/backends/arm/runtime/targets.bzl index 7bed02c281e..896c1a6aa06 100644 --- a/backends/arm/runtime/targets.bzl +++ b/backends/arm/runtime/targets.bzl @@ -33,9 +33,12 @@ def define_common_targets(): srcs = ["EthosUExecutionStats.cpp"], compatible_with = ["ovr_config//cpu:arm32-embedded", "ovr_config//cpu:arm32-embedded-fpu"], exported_headers = ["EthosUExecutionStats.h"], - exported_deps = [":ethosu_invocation_context"], + exported_deps = [ + ":ethosu_invocation_context", + "//executorch/runtime/core:core", + "fbsource//third-party/ethos-u-core-driver:core_driver_headers_only", + ], visibility = ["PUBLIC"], - deps = ["fbsource//third-party/ethos-u-core-driver:core_driver_headers_only"], ) runtime.cxx_library( name = "vela_bin_stream", diff --git a/backends/arm/test/ethosu_pmu_callbacks_test.cpp b/backends/arm/test/ethosu_pmu_callbacks_test.cpp index 8914f60ef4b..7ba3f910a0b 100644 --- a/backends/arm/test/ethosu_pmu_callbacks_test.cpp +++ b/backends/arm/test/ethosu_pmu_callbacks_test.cpp @@ -13,6 +13,7 @@ #include "ethosu_pmu_test_stubs.h" +#include #include #include #include @@ -267,5 +268,125 @@ int main() { CHECK(read_le(other_tracer.metadata + offset + 12, 4) == (slot == 3)); } CHECK(buffer.size == 2); + + CHECK(pmu(&u85).events[7] == ETHOSU_PMU_NO_EVENT); + CHECK((pmu(&u85).enabled_mask & 0xff) == 0x7f); + + ethosu_pmu_event_type u55_events[] = {ETHOSU_PMU_MAC_ACTIVE}; + CHECK(set_ethosu55_65_pmu_events(u55_events) == Error::Ok); + u55_events[0] = ETHOSU_PMU_NPU_IDLE; + std::array too_many_events{}; + std::array too_many_u55_events{}; + CHECK(set_ethosu85_pmu_events(too_many_events) == Error::InvalidArgument); + CHECK( + set_ethosu55_65_pmu_events(too_many_u55_events) == + Error::InvalidArgument); + + buffer.reset(); + CHECK(set_ethosu_stats_buffer(&buffer) == nullptr); + EthosUInvocationContext u55_invocation{}; + ethosu_inference_begin(&u55, &u55_invocation); + CHECK((pmu(&u55).enabled_mask & 0xf) == 1); + CHECK(pmu(&u55).events[0] == ETHOSU_PMU_MAC_ACTIVE); + CHECK(pmu(&u55).events[3] == ETHOSU_PMU_NO_EVENT); + pmu(&u55).counts[0] = 91; + CHECK(set_ethosu55_65_pmu_events({}) == Error::Ok); + ethosu_inference_end(&u55, &u55_invocation); + CHECK(buffer.size == 1); + CHECK(records[0].stats.counter_count == 1); + CHECK(records[0].stats.counters[0].event_id == ETHOSU_PMU_MAC_ACTIVE); + CHECK(records[0].stats.counters[0].value == 91); + CHECK(set_ethosu_stats_buffer(nullptr) == &buffer); + + ethosu_pmu_event_type u55_configured_events[] = { + ETHOSU_PMU_NPU_IDLE, + ETHOSU_PMU_NO_EVENT, + ETHOSU_PMU_MAC_ACTIVE, + ETHOSU_PMU_WD_ACTIVE, + }; + ethosu_pmu_event_type u85_configured_events[] = { + ETHOSU_PMU_NPU_IDLE, + ETHOSU_PMU_NO_EVENT, + ETHOSU_PMU_MAC_ACTIVE, + ETHOSU_PMU_WD_ACTIVE, + ETHOSU_PMU_NPU_ACTIVE, + }; + CHECK(set_ethosu55_65_pmu_events(u55_configured_events) == Error::Ok); + CHECK(set_ethosu85_pmu_events(u85_configured_events) == Error::Ok); + u55_configured_events[0] = ETHOSU_PMU_NO_EVENT; + u85_configured_events[0] = ETHOSU_PMU_NO_EVENT; + CHECK(set_ethosu85_pmu_events(too_many_events) == Error::InvalidArgument); + buffer.reset(); + CHECK(set_ethosu_stats_buffer(&buffer) == nullptr); + EthosUInvocationContext configured_u55{}; + ethosu_inference_begin(&u55, &configured_u55); + CHECK((pmu(&u55).enabled_mask & 0xf) == 0xd); + CHECK(pmu(&u55).events[0] == ETHOSU_PMU_NPU_IDLE); + CHECK(pmu(&u55).events[1] == ETHOSU_PMU_NO_EVENT); + CHECK(pmu(&u55).events[3] == ETHOSU_PMU_WD_ACTIVE); + ethosu_inference_end(&u55, &configured_u55); + CapturingTracer sparse_tracer; + EthosUInvocationContext configured_u85{}; + configured_u85.event_tracer = &sparse_tracer; + ethosu_inference_begin(&u85, &configured_u85); + CHECK((pmu(&u85).enabled_mask & 0xff) == 0x1d); + CHECK(pmu(&u85).events[0] == ETHOSU_PMU_NPU_IDLE); + CHECK(pmu(&u85).events[4] == ETHOSU_PMU_NPU_ACTIVE); + CHECK(pmu(&u85).events[7] == ETHOSU_PMU_NO_EVENT); + pmu(&u85).counts[2] = 31; + pmu(&u85).counts[4] = 59; + ethosu_inference_end(&u85, &configured_u85); + CHECK(buffer.size == 2); + CHECK(records[0].stats.counter_count == 3); + CHECK(records[0].stats.counters[1].event_id == ETHOSU_PMU_MAC_ACTIVE); + CHECK(records[1].stats.counter_count == 4); + CHECK(records[1].stats.counters[3].event_id == ETHOSU_PMU_NPU_ACTIVE); + CHECK(records[1].stats.counters[1].value == 31); + CHECK(records[1].stats.counters[3].value == 59); + CHECK(sparse_tracer.started && sparse_tracer.ended); + CHECK( + sparse_tracer.metadata_size == + kEthosUCycleMetadataHeaderSize + 4 * kEthosUCycleCounterMetadataSize); + CHECK(read_le(sparse_tracer.metadata + 36, 4) == 4); + CHECK( + read_le( + sparse_tracer.metadata + kEthosUCycleMetadataHeaderSize + + kEthosUCycleCounterMetadataSize, + 4) == ETHOSU_PMU_MAC_ACTIVE); + CHECK( + read_le( + sparse_tracer.metadata + kEthosUCycleMetadataHeaderSize + + 3 * kEthosUCycleCounterMetadataSize + 4, + 8) == 59); + CHECK(set_ethosu_stats_buffer(nullptr) == &buffer); + + CHECK( + set_ethosu85_pmu_events( + std::array{ETHOSU_PMU_MAC_ACTIVE, ETHOSU_PMU_NO_EVENT}) == Error::Ok); + buffer.reset(); + CHECK(set_ethosu_stats_buffer(&buffer) == nullptr); + EthosUInvocationContext short_u85{}; + ethosu_inference_begin(&u85, &short_u85); + CHECK((pmu(&u85).enabled_mask & 0xff) == 1); + CHECK(pmu(&u85).events[0] == ETHOSU_PMU_MAC_ACTIVE); + CHECK(pmu(&u85).events[7] == ETHOSU_PMU_NO_EVENT); + ethosu_inference_end(&u85, &short_u85); + CHECK(buffer.size == 1); + CHECK(records[0].stats.counter_count == 1); + CHECK(set_ethosu_stats_buffer(nullptr) == &buffer); + + CHECK(set_ethosu85_pmu_events({}) == Error::Ok); + CHECK(set_ethosu55_65_pmu_events({}) == Error::Ok); + buffer.reset(); + CHECK(set_ethosu_stats_buffer(&buffer) == nullptr); + EthosUInvocationContext cycles_only{}; + ethosu_inference_begin(&u55, &cycles_only); + CHECK((pmu(&u55).enabled_mask & 0xf) == 0); + pmu(&u55).cycles = 77; + ethosu_inference_end(&u55, &cycles_only); + CHECK(buffer.size == 1); + CHECK(records[0].stats.counter_count == 0); + CHECK(records[0].stats.cycle_count == 77); + CHECK(set_ethosu_stats_buffer(nullptr) == &buffer); std::puts("Ethos-U PMU callbacks: PASS"); } diff --git a/backends/arm/test/ethosu_pmu_test_stubs.cpp b/backends/arm/test/ethosu_pmu_test_stubs.cpp index 144a45e08e6..b9ab8a9e641 100644 --- a/backends/arm/test/ethosu_pmu_test_stubs.cpp +++ b/backends/arm/test/ethosu_pmu_test_stubs.cpp @@ -63,7 +63,11 @@ void ETHOSU_PMU_PMCCNTR_CFG_Set_Start_Event( ethosu_pmu_event_type) {} void ETHOSU_PMU_CNTR_Enable(ethosu_driver* driver, uint32_t mask) { - pmu(driver).enabled_mask = mask; + pmu(driver).enabled_mask |= mask; +} + +void ETHOSU_PMU_CNTR_Disable(ethosu_driver* driver, uint32_t mask) { + pmu(driver).enabled_mask &= ~mask; } void ETHOSU_PMU_CYCCNT_Reset(ethosu_driver* driver) { From 459d5341d67a375dd1b48f938b990c9489f976ce Mon Sep 17 00:00:00 2001 From: Bryson Miller Date: Wed, 4 Nov 2026 11:44:07 -0500 Subject: [PATCH 052/128] Arm backend: Delegate int64 only to the operators EXT-INT64 covers (2/3) (#24306) ### Summary Adds `TOSA_PRO_INT_EXT_INT64_SupportList`, operators that lower to TOSA operators `EXT-INT64` defines for i64 under PRO-INT, and delegates int64 only to those. The list is not exhaustive. Today the int64 check is skipped entirely when the extension is declared, so an operator that cannot carry int64 is accepted and fails later, for example `cumsum` under `TOSA-1.1+INT+int64`. An operator absent from the list now keeps the behavior it has without the extension. `MatchArgDtypePass` now also casts mismatched operands of `cat` and the bitwise operators, so a listed operator delegates when its other operands are int32 or bool, the types TOSA casts to i64. Comparisons, `where`, `slice_copy`, `full_like` and `zeros_like` stay off the list until part 3. Part 2 of 3 for #24095, following #24128. ### Test Plan Added partitioning tests under `TOSA-1.1+INT+int64`: a listed operator on int64 operands delegates, while unlisted operators and operands narrower than int32 fall back to CPU. Ten of these cases fail on main. 44 single-op models: identical to main under `TOSA-1.0+INT`, and under `TOSA-1.1+INT+int64` `cumsum` falls back instead of failing and `sum_dim` falls back as it does without the extension. This PR was authored with Claude Code. --- backends/arm/_passes/match_arg_dtype_pass.py | 30 ++++++--- .../tosa_profile_supported_op_lists.py | 31 ++++++++++ .../tosa_supported_operators.py | 29 +++++++-- backends/arm/test/misc/test_int64.py | 62 +++++++++++++++++++ .../test/misc/test_tosa_operator_support.py | 11 ++++ 5 files changed, 148 insertions(+), 15 deletions(-) diff --git a/backends/arm/_passes/match_arg_dtype_pass.py b/backends/arm/_passes/match_arg_dtype_pass.py index a952b9bf062..fd9ea0059e0 100644 --- a/backends/arm/_passes/match_arg_dtype_pass.py +++ b/backends/arm/_passes/match_arg_dtype_pass.py @@ -3,6 +3,7 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. +from functools import reduce from typing import Set, Type import torch @@ -33,6 +34,10 @@ class MatchArgDtypePass(ArmOpTargetedPass): exir_ops.edge.aten.add.Tensor, exir_ops.edge.aten.mul.Tensor, exir_ops.edge.aten.where.self, + exir_ops.edge.aten.bitwise_and.Tensor, + exir_ops.edge.aten.bitwise_or.Tensor, + exir_ops.edge.aten.bitwise_xor.Tensor, + exir_ops.edge.aten.cat.default, } def call(self, graph_module: torch.fx.GraphModule): @@ -42,20 +47,27 @@ def call(self, graph_module: torch.fx.GraphModule): for node in graph.nodes: if node.op != "call_function" or node.target not in self.target_ops: continue - args_to_cast = ( - node.args[1:] - if node.target == exir_ops.edge.aten.where.self - else node.args - ) - input_ = args_to_cast[0] - other_ = args_to_cast[1] - target_dtype = torch.result_type(input_.meta["val"], other_.meta["val"]) + if node.target == exir_ops.edge.aten.cat.default: + args_to_cast = node.args[0] + target_dtype = reduce( + torch.promote_types, + (arg.meta["val"].dtype for arg in args_to_cast), + ) + else: + args_to_cast = ( + node.args[1:] + if node.target == exir_ops.edge.aten.where.self + else node.args + ) + input_ = args_to_cast[0] + other_ = args_to_cast[1] + target_dtype = torch.result_type(input_.meta["val"], other_.meta["val"]) for arg in args_to_cast: arg_dtype = arg.meta["val"].dtype if arg_dtype != target_dtype: - if arg_dtype == torch.bool: + if arg_dtype == torch.bool and target_dtype != torch.int64: # Bool is an edge case which cannot necessarily be directly # converted to the target data type. with graph.inserting_after(arg): diff --git a/backends/arm/operator_support/tosa_profile_supported_op_lists.py b/backends/arm/operator_support/tosa_profile_supported_op_lists.py index d8f7ab54c74..5957e2f006b 100644 --- a/backends/arm/operator_support/tosa_profile_supported_op_lists.py +++ b/backends/arm/operator_support/tosa_profile_supported_op_lists.py @@ -362,11 +362,42 @@ operator.floordiv, } +# Operators that delegate with an int64 operand under PRO-INT with EXT-INT64. +# Each lowers to TOSA operators that EXT-INT64 defines for i64 under PRO-INT. +# The list is not exhaustive. An absent operator narrows to int32 the way it +# does without the extension. +TOSA_PRO_INT_EXT_INT64_SupportList: Final[Set] = { + exir_ops.edge.dim_order_ops._to_dim_order_copy.default, + exir_ops.edge.aten.abs.default, + exir_ops.edge.aten.add.Tensor, + exir_ops.edge.aten.bitwise_and.Tensor, + exir_ops.edge.aten.bitwise_not.default, + exir_ops.edge.aten.bitwise_or.Tensor, + exir_ops.edge.aten.bitwise_xor.Tensor, + exir_ops.edge.aten.cat.default, + exir_ops.edge.aten.constant_pad_nd.default, + exir_ops.edge.aten.expand_copy.default, + exir_ops.edge.aten.flip.default, + exir_ops.edge.aten.maximum.default, + exir_ops.edge.aten.minimum.default, + exir_ops.edge.aten.mul.Tensor, + exir_ops.edge.aten.neg.default, + exir_ops.edge.aten.pad.default, + exir_ops.edge.aten.permute_copy.default, + exir_ops.edge.aten.repeat.default, + exir_ops.edge.aten.select_copy.int, + exir_ops.edge.aten.squeeze_copy.dims, + exir_ops.edge.aten.sub.Tensor, + exir_ops.edge.aten.unsqueeze_copy.default, + exir_ops.edge.aten.view_copy.default, +} + __all__ = [ "TOSA_EXT_CONTROL_FLOW_SupportList", "TOSA_EXT_MXFP_SupportList", "TOSA_EXT_SHAPE_SupportList", + "TOSA_PRO_INT_EXT_INT64_SupportList", "TOSA_PRO_INT_SupportList", "TOSA_PRO_MIXED_INT_SupportList", "TOSA_PRO_FP_SupportList", diff --git a/backends/arm/operator_support/tosa_supported_operators.py b/backends/arm/operator_support/tosa_supported_operators.py index 97d8cc7e2aa..1c04406ee7f 100644 --- a/backends/arm/operator_support/tosa_supported_operators.py +++ b/backends/arm/operator_support/tosa_supported_operators.py @@ -70,6 +70,7 @@ TOSA_EXT_MXFP_SupportList, TOSA_EXT_SHAPE_SupportList, TOSA_PRO_FP_SupportList, + TOSA_PRO_INT_EXT_INT64_SupportList, TOSA_PRO_INT_SupportList, TOSA_PRO_MIXED_INT_SupportList, ) @@ -643,13 +644,12 @@ def _negative_checks( if tosa_spec.support_integer() and tosa_spec.support_float(): checks.append(CheckMixedProfileOperatorSupport(reporter)) - if not tosa_spec.support_extension("int64"): - checks.append( - _AllowDynamicW8A8QParamsOr( - CheckInt64InputsAndOutputs(exported_program, reporter, tosa_spec), - tosa_spec, - ) + checks.append( + _AllowDynamicW8A8QParamsOr( + CheckInt64InputsAndOutputs(exported_program, reporter, tosa_spec), + tosa_spec, ) + ) checks.append(CheckScalarReductionInputs(reporter)) @@ -1206,6 +1206,16 @@ def is_node_supported( return True +def _int64_operands_agree(node: fx.Node) -> bool: + """Whether every operand beside an int64 operand casts to int64.""" + dtypes = { + arg.meta["val"].dtype + for arg in node.all_input_nodes + if isinstance(arg.meta.get("val"), torch.Tensor) + } + return torch.int64 not in dtypes or dtypes <= {torch.int64, torch.int32, torch.bool} + + class CheckInt64InputsAndOutputs(OperatorSupportBase): """Reject general int64 tensors while allowing safe exceptions. @@ -1431,6 +1441,13 @@ def is_node_supported( """Return True when int64 use is absent or safe per exceptions.""" if is_submodule_node(node): return True + if ( + self.tosa_spec.support_integer() + and self.tosa_spec.support_extension("int64") + and node.target in TOSA_PRO_INT_EXT_INT64_SupportList + and _int64_operands_agree(node) + ): + return True vals = node.meta["val"] tensor_list = vals if isinstance(vals, (list, tuple)) else [vals] diff --git a/backends/arm/test/misc/test_int64.py b/backends/arm/test/misc/test_int64.py index 46a97fff1df..6bf6381b1ef 100644 --- a/backends/arm/test/misc/test_int64.py +++ b/backends/arm/test/misc/test_int64.py @@ -110,3 +110,65 @@ def test_int64_tosa_INT(test_data: Tuple): .to_executorch() .run_method_and_compare_outputs(inputs) ) + + +class Int64Op(torch.nn.Module): + def __init__(self, op): + super().__init__() + self.op = op + + def forward(self, *args): + return self.op(*args) + + +_WIDE = torch.tensor([2**40, -(2**38), 2**31, -1], dtype=torch.int64) +_WIDE_2 = torch.tensor([2**39, 2**37, 1, -(2**40)], dtype=torch.int64) +_NARROW = torch.tensor([1, 2, 3, 4], dtype=torch.int32) +_BYTES = torch.tensor([1, 2, 3, 4], dtype=torch.int8) +_COND = torch.tensor([True, False, True, False]) +_FLAGS = torch.tensor([False, True, True, False]) + + +class Int64BufferWhere(torch.nn.Module): + def __init__(self): + super().__init__() + self.register_buffer("wide", _WIDE) + + def forward(self, cond, x): + return torch.where(cond, x, self.wide) + + +# A listed operator delegates when every operand casts to int64; an unlisted +# operator or an operand narrower than int32 falls back instead. +int64_extension_partition_suite = { + "add": (lambda x, y: x + y, (_WIDE, _WIDE_2), 1), + "pad": (lambda x: torch.nn.functional.pad(x, (1, 1)), (_WIDE,), 1), + "cumsum": (lambda x: torch.cumsum(x, 0), (_WIDE,), 0), + "cat_narrow": (lambda x, y: torch.cat([x, y]), (_WIDE, _NARROW), 1), + "or_narrow": (torch.bitwise_or, (_WIDE, _NARROW), 1), + "cat_byte": (lambda x, y: torch.cat([x, y]), (_WIDE, _BYTES), 0), + "eq_bool": (lambda x, y: x == y, (_WIDE, _FLAGS), 0), + "cat_bool": (lambda x, y: torch.cat([x, y]), (_FLAGS, _WIDE), 1), + "where": (torch.where, (_COND, _WIDE, _WIDE_2), 0), + "where_buffer": (Int64BufferWhere(), (_COND, _WIDE_2), 0), + "strided_slice": (lambda x: x[::2], (torch.arange(5, dtype=torch.int64),), 0), + "gt": (lambda x, y: x > y, (_WIDE, _WIDE_2), 0), + "gt_scalar": (lambda x: x > 7, (_WIDE,), 0), + "full_like": (lambda x: torch.full_like(x, 5) + x, (_WIDE,), 1), + "zeros_like": (lambda x: torch.zeros_like(x) + x, (_WIDE,), 1), +} + + +@common.parametrize("test_data", int64_extension_partition_suite) +def test_int64_extension_partition_tosa_INT(test_data: Tuple): + op, inputs, delegates = test_data + ( + ArmTester( + op if isinstance(op, torch.nn.Module) else Int64Op(op), + inputs, + common.get_tosa_compile_spec("TOSA-1.1+INT+int64"), + ) + .export() + .to_edge_transform_and_lower() + .check_count({"torch.ops.higher_order.executorch_call_delegate": delegates}) + ) diff --git a/backends/arm/test/misc/test_tosa_operator_support.py b/backends/arm/test/misc/test_tosa_operator_support.py index 635566dffdf..25c40d63905 100644 --- a/backends/arm/test/misc/test_tosa_operator_support.py +++ b/backends/arm/test/misc/test_tosa_operator_support.py @@ -13,6 +13,7 @@ ) from executorch.backends.arm.operator_support.topk_support import TopKSupported from executorch.backends.arm.operator_support.tosa_supported_operators import ( + _int64_operands_agree, CheckFPComparisonInputs, CheckKnownUnsupportedTOSASemantics, ) @@ -323,3 +324,13 @@ def test_topk_keyword_arguments(dim) -> None: ) assert config is not None and config.k == 4 assert reason is None + + +def test_int64_operand_check_skips_symbolic_inputs() -> None: + graph = torch.fx.Graph() + x = _placeholder(graph, "x", (8,), torch.int32) + size = graph.placeholder("size") + size.meta["val"] = ShapeEnv().create_unbacked_symint() + node = graph.call_function(exir_ops.edge.aten.view_copy.default, (x, [size, -1])) + + assert _int64_operands_agree(node) From 6287519d4ba05853bba8ec5ca863e47f5694dc69 Mon Sep 17 00:00:00 2001 From: Adrian Lundell <36153706+AdrianLundell@users.noreply.github.com> Date: Wed, 4 Nov 2026 17:55:40 +0100 Subject: [PATCH 053/128] transforms: propagate permute/views through binary ops (#24229) --- .../propagate_view_copy_permute_pass.py | 11 +- .../test/models/test_upsample_slice_concat.py | 62 ++ .../test_propagate_permutes_views_pass.py | 586 +++++++++++++++++- backends/arm/test/targets.bzl | 10 +- .../propagate_view_copy_permute_pass.py | 300 +++++++-- backends/transforms/targets.bzl | 2 + 6 files changed, 900 insertions(+), 71 deletions(-) create mode 100644 backends/arm/test/models/test_upsample_slice_concat.py diff --git a/backends/arm/_passes/propagate_view_copy_permute_pass.py b/backends/arm/_passes/propagate_view_copy_permute_pass.py index ee3ad6c8257..677ae48d5af 100644 --- a/backends/arm/_passes/propagate_view_copy_permute_pass.py +++ b/backends/arm/_passes/propagate_view_copy_permute_pass.py @@ -116,17 +116,22 @@ def is_swappable(self, next_node: torch.fx.Node) -> bool: ) return super().is_swappable(next_node) - def is_multi_input_elementwise(self, node: torch.fx.Node) -> bool: + def is_backend_elementwise(self, node: torch.fx.Node) -> bool: return node.target == exir_ops.backend.tosa.TABLE.default + def _layout_dependent_inputs( + self, node: torch.fx.Node, rank: int + ) -> list[torch.fx.Node]: + if node.target == exir_ops.backend.tosa.TABLE.default: + return list(node.all_input_nodes[:1]) + return super()._layout_dependent_inputs(node, rank) + def blocks_crossing(self, node: torch.fx.Node) -> bool: return node.target == exir_ops.backend.tosa.SCATTER.default def is_elementwise(self, node: torch.fx.Node) -> bool: if node.target == exir_ops.backend.tosa.RESCALE.default: return self._is_per_tensor_rescale(node) - if node.target == exir_ops.backend.tosa.TABLE.default: - return True return super().is_elementwise(node) def _is_per_tensor_rescale(self, node: torch.fx.Node) -> bool: diff --git a/backends/arm/test/models/test_upsample_slice_concat.py b/backends/arm/test/models/test_upsample_slice_concat.py new file mode 100644 index 00000000000..b71c1ecdd31 --- /dev/null +++ b/backends/arm/test/models/test_upsample_slice_concat.py @@ -0,0 +1,62 @@ +# Copyright 2026 Arm Limited and/or its affiliates. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import torch +from executorch.backends.arm.test.tester.test_pipeline import ( + TosaPipelineFP, + TosaPipelineINT, +) + + +class UpsampleSliceConcat(torch.nn.Module): + def __init__(self) -> None: + super().__init__() + self.upsample = torch.nn.Sequential( + torch.nn.ConvTranspose2d(8, 24, 4, stride=2, padding=1), + torch.nn.PixelShuffle(2), + torch.nn.Upsample(scale_factor=2, mode="bilinear", align_corners=False), + ) + + def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, ...]: + x = self.upsample(x) + outputs = [] + for start in (0, 2): + for offset in range(9): + branch = x[:, start : start + 2] + float(offset) + outputs.append( + torch.cat([branch[:, :1] / 2.0, branch[:, 1:] / 4.0], dim=1) + ) + return tuple(outputs) + + +model = UpsampleSliceConcat().eval() +model_inputs = (torch.randn(1, 8, 3, 5),) +input_t = tuple[torch.Tensor] + + +def test_upsample_slice_concat_tosa_FP() -> None: + pipeline = TosaPipelineFP[input_t]( + model, + model_inputs, + aten_op=[], + exir_op=[], + use_to_edge_transform_and_lower=True, + ) + pipeline.count_tosa_ops({"TRANSPOSE": 3, "RESHAPE": 2}) + pipeline.run() + + +def test_upsample_slice_concat_tosa_INT() -> None: + pipeline = TosaPipelineINT[input_t]( + model, + model_inputs, + aten_op=[], + exir_op=[], + use_to_edge_transform_and_lower=True, + run_on_tosa_ref_model=False, + ) + pipeline.pop_stage("to_executorch") + pipeline.count_tosa_ops({"TRANSPOSE": 3, "RESHAPE": 2}) + pipeline.run() diff --git a/backends/arm/test/passes/test_propagate_permutes_views_pass.py b/backends/arm/test/passes/test_propagate_permutes_views_pass.py index a5c662ab291..111cb51e2e9 100644 --- a/backends/arm/test/passes/test_propagate_permutes_views_pass.py +++ b/backends/arm/test/passes/test_propagate_permutes_views_pass.py @@ -22,6 +22,10 @@ TosaLoweringContext, TosaSpecification, ) +from executorch.backends.transforms.fuse_identical_input_transforms_pass import ( + FuseIdenticalInputTransformsPass, +) +from executorch.backends.transforms.utils import get_param_tensor from executorch.exir import ExportedProgram from executorch.exir.dialects._ops import ops as exir_ops from torch._subclasses.fake_tensor import FakeTensorMode @@ -49,6 +53,15 @@ SLICE = exir_ops.edge.aten.slice_copy.Tensor SUM = exir_ops.edge.aten.sum.dim_IntList MEAN = exir_ops.edge.aten.mean.dim +BINARY_OPS = sorted(FuseIdenticalInputTransformsPass._BINARY_ELEMENTWISE_OPS, key=str) +COMPARISON_OPS = { + exir_ops.edge.aten.eq.Tensor, + exir_ops.edge.aten.gt.Tensor, + exir_ops.edge.aten.ge.Tensor, + exir_ops.edge.aten.lt.Tensor, + exir_ops.edge.aten.le.Tensor, + exir_ops.edge.aten.ne.Tensor, +} def _assert_call_targets( @@ -299,20 +312,37 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: class StopAtParameter(torch.nn.Module): def __init__(self) -> None: super().__init__() - self.weight = torch.nn.Parameter(torch.randn(1, 2, 3, 4)) + self.weight = torch.nn.Parameter(torch.randn(2, 1, 1)) def forward(self, x: torch.Tensor) -> torch.Tensor: - return (x + self.weight).permute(0, 2, 3, 1) + return (self.weight - x).permute(0, 2, 3, 1) data = (torch.randn(1, 2, 3, 4),) def test_propagate_moves_before_parameter_tosa_FP() -> None: def predicate(targets: list[object]) -> None: - assert targets.index(ADD) < targets.index(PERMUTE) + assert targets.index(PERMUTE) < targets.index(SUB) + + module = StopAtParameter() + expected_weight = module.weight.detach().reshape(1, 2, 1, 1).permute(0, 2, 3, 1) + + def check_parameter(exported_program: ExportedProgram) -> ExportedProgram: + parameter = next( + node + for node in exported_program.graph.nodes + if node.name in exported_program.graph_signature.inputs_to_parameters + ) + torch.testing.assert_close( + get_param_tensor(exported_program, parameter), expected_weight + ) + sub = next(node for node in exported_program.graph.nodes if node.target == SUB) + assert sub.args[0] is parameter + assert sub.args[1].target == PERMUTE + return exported_program pipeline = PassPipeline[input_t]( - StopAtParameter(), + module, StopAtParameter.data, quantize=False, ops_before_pass={ @@ -322,11 +352,388 @@ def predicate(targets: list[object]) -> None: "executorch_exir_dialects_edge__ops_aten_permute_copy_default": 1, }, pass_list=[PropagateViewCopyPermuteUpPass], + pass_functions=[_assert_call_targets(predicate), check_parameter], + ) + pipeline.run() + + +class RepeatedParameter(torch.nn.Module): + def __init__(self) -> None: + super().__init__() + self.weight = torch.nn.Parameter(torch.tensor([[0.0, 1.0], [2.0, 3.0]])) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + return (self.weight + self.weight).permute(1, 0) + x + + data = (torch.zeros(2, 2),) + + +def test_propagate_up_stops_at_repeated_parameter_tosa_FP() -> None: + module = RepeatedParameter() + expected_weight = module.weight.detach().clone() + + def check(exported_program: ExportedProgram) -> ExportedProgram: + parameter = next( + node + for node in exported_program.graph.nodes + if node.name in exported_program.graph_signature.inputs_to_parameters + ) + torch.testing.assert_close( + get_param_tensor(exported_program, parameter), expected_weight + ) + inner_add = next( + node + for node in exported_program.graph.nodes + if node.target == ADD and node.args == (parameter, parameter) + ) + permute = next( + node for node in exported_program.graph.nodes if node.target == PERMUTE + ) + nodes = list(exported_program.graph.nodes) + assert nodes.index(inner_add) < nodes.index(permute) + return exported_program + + pipeline = PassPipeline[input_t]( + module, + RepeatedParameter.data, + quantize=False, + pass_list=[PropagateViewCopyPermuteUpPass], + pass_functions=[check], + ) + pipeline.run() + + +class DownwardParameter(torch.nn.Module): + def __init__(self) -> None: + super().__init__() + self.weight = torch.nn.Parameter(torch.randn(2)) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + return x.permute(0, 2, 3, 1) - self.weight + + data = (torch.randn(1, 2, 3, 4),) + + +def test_propagate_down_moves_before_parameter_tosa_FP() -> None: + module = DownwardParameter() + expected_weight = module.weight.detach().reshape(1, 1, 1, 2).permute(0, 3, 1, 2) + + def check(exported_program: ExportedProgram) -> ExportedProgram: + call_targets = [ + node.target + for node in exported_program.graph.nodes + if node.op == "call_function" + ] + assert call_targets.index(SUB) < call_targets.index(PERMUTE) + parameter = next( + node + for node in exported_program.graph.nodes + if node.name in exported_program.graph_signature.inputs_to_parameters + ) + torch.testing.assert_close( + get_param_tensor(exported_program, parameter), expected_weight + ) + sub = next(node for node in exported_program.graph.nodes if node.target == SUB) + assert sub.args[0].name == "x" + assert sub.args[1] is parameter + return exported_program + + pipeline = PassPipeline[input_t]( + module, + DownwardParameter.data, + quantize=False, + ops_before_pass={ + "executorch_exir_dialects_edge__ops_aten_permute_copy_default": 1, + }, + ops_after_pass={ + "executorch_exir_dialects_edge__ops_aten_permute_copy_default": 1, + }, + pass_list=[PropagateViewCopyPermuteDownPass], + pass_functions=[check], + ) + pipeline.run() + + +class RankExpandingDownwardParameter(torch.nn.Module): + def __init__(self) -> None: + super().__init__() + self.weight = torch.nn.Parameter(torch.randn(1, 1, 3, 2)) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + return x.permute(1, 0) - self.weight + + data = (torch.randn(2, 3),) + + +def test_propagate_down_lifts_permute_for_rank_expanding_parameter_tosa_FP() -> None: + module = RankExpandingDownwardParameter() + expected_weight = module.weight.detach().permute(0, 1, 3, 2) + + def check(exported_program: ExportedProgram) -> ExportedProgram: + parameter = next( + node + for node in exported_program.graph.nodes + if node.name in exported_program.graph_signature.inputs_to_parameters + ) + torch.testing.assert_close( + get_param_tensor(exported_program, parameter), expected_weight + ) + sub = next(node for node in exported_program.graph.nodes if node.target == SUB) + moved_permute = next( + node for node in exported_program.graph.nodes if node.target == PERMUTE + ) + assert sub.args[0].name == "x" + assert sub.args[1] is parameter + assert moved_permute.args[1] == [0, 1, 3, 2] + return exported_program + + pipeline = PassPipeline[input_t]( + module, + RankExpandingDownwardParameter.data, + quantize=False, + pass_list=[PropagateViewCopyPermuteDownPass], + pass_functions=[check], + ) + pipeline.run() + + +class RankExpandingUpwardParameter(torch.nn.Module): + def __init__(self) -> None: + super().__init__() + self.weight = torch.nn.Parameter(torch.randn(1, 1, 2, 3)) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + return (self.weight - x).permute(0, 1, 3, 2) + + data = (torch.randn(2, 3),) + + +def test_propagate_up_projects_permute_for_rank_expanding_parameter_tosa_FP() -> None: + module = RankExpandingUpwardParameter() + expected_weight = module.weight.detach().permute(0, 1, 3, 2) + + def check(exported_program: ExportedProgram) -> ExportedProgram: + parameter = next( + node + for node in exported_program.graph.nodes + if node.name in exported_program.graph_signature.inputs_to_parameters + ) + torch.testing.assert_close( + get_param_tensor(exported_program, parameter), expected_weight + ) + sub = next(node for node in exported_program.graph.nodes if node.target == SUB) + moved_permute = next( + node for node in exported_program.graph.nodes if node.target == PERMUTE + ) + assert sub.args[0] is parameter + assert sub.args[1] is moved_permute + assert moved_permute.args[1] == [1, 0] + return exported_program + + pipeline = PassPipeline[input_t]( + module, + RankExpandingUpwardParameter.data, + quantize=False, + pass_list=[PropagateViewCopyPermuteUpPass], + pass_functions=[check], + ) + pipeline.run() + + +class UnprojectableRankExpandingUpwardParameter(torch.nn.Module): + def __init__(self) -> None: + super().__init__() + self.weight = torch.nn.Parameter(torch.randn(4, 1, 2, 3)) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + return (self.weight - x).permute(2, 1, 0, 3) + + data = (torch.randn(2, 3),) + + +def test_propagate_up_rejects_permute_mixing_added_dims_tosa_FP() -> None: + module = UnprojectableRankExpandingUpwardParameter() + expected_weight = module.weight.detach().clone() + + def check(exported_program: ExportedProgram) -> ExportedProgram: + targets = [ + node.target + for node in exported_program.graph.nodes + if node.op == "call_function" + ] + assert targets.index(SUB) < targets.index(PERMUTE) + parameter = next( + node + for node in exported_program.graph.nodes + if node.name in exported_program.graph_signature.inputs_to_parameters + ) + torch.testing.assert_close( + get_param_tensor(exported_program, parameter), expected_weight + ) + return exported_program + + pipeline = PassPipeline[input_t]( + module, + UnprojectableRankExpandingUpwardParameter.data, + quantize=False, + pass_list=[PropagateViewCopyPermuteUpPass], + pass_functions=[check], + ) + pipeline.run() + + +class DownwardForkParameter(torch.nn.Module): + def __init__(self) -> None: + super().__init__() + self.weight = torch.nn.Parameter(torch.tensor([10.0, 20.0])) + + def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: + y = x.permute(0, 2, 3, 1) + return y - self.weight, -y + + data = (torch.arange(8, dtype=torch.float32).reshape(1, 2, 2, 2),) + + +def test_propagate_down_updates_parameter_when_splitting_fork_tosa_FP() -> None: + module = DownwardForkParameter() + expected_weight = module.weight.detach().reshape(1, 1, 1, 2).permute(0, 3, 1, 2) + + def check(exported_program: ExportedProgram) -> ExportedProgram: + parameter = next( + node + for node in exported_program.graph.nodes + if node.name in exported_program.graph_signature.inputs_to_parameters + ) + torch.testing.assert_close( + get_param_tensor(exported_program, parameter), expected_weight + ) + call_targets = [ + node.target + for node in exported_program.graph.nodes + if node.op == "call_function" + ] + assert call_targets.count(PERMUTE) == 2 + assert call_targets.index(SUB) < call_targets.index(PERMUTE) + return exported_program + + pipeline = PassPipeline[input_t]( + module, + DownwardForkParameter.data, + quantize=False, + pass_list=[PropagateViewCopyPermuteDownPass], + pass_functions=[check], + ) + pipeline.run() + + +class RankExpandingDownwardForkParameter(torch.nn.Module): + def __init__(self, leading_size: int) -> None: + super().__init__() + self.weight = torch.nn.Parameter(torch.randn(leading_size, 3, 4, 2)) + + def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: + y = x.permute(1, 2, 0) + return y - self.weight, -y + + data = (torch.randn(2, 3, 4),) + + +@pytest.mark.parametrize("leading_size", [1, 5]) +def test_propagate_down_updates_rank_expanding_parameter_at_fork_tosa_FP( + leading_size: int, +) -> None: + module = RankExpandingDownwardForkParameter(leading_size) + expected_weight = module.weight.detach().permute(0, 3, 1, 2) + + def check(exported_program: ExportedProgram) -> ExportedProgram: + parameter = next( + node + for node in exported_program.graph.nodes + if node.name in exported_program.graph_signature.inputs_to_parameters + ) + torch.testing.assert_close( + get_param_tensor(exported_program, parameter), expected_weight + ) + sub = next(node for node in exported_program.graph.nodes if node.target == SUB) + neg = next(node for node in exported_program.graph.nodes if node.target == NEG) + assert sub.args[0].name == "x" + assert sub.args[1] is parameter + assert neg.args[0].name == "x" + permutes = { + node.args[0]: node.args[1] + for node in exported_program.graph.nodes + if node.target == PERMUTE + } + assert permutes == {sub: [0, 2, 3, 1], neg: [1, 2, 0]} + return exported_program + + pipeline = PassPipeline[input_t]( + module, + RankExpandingDownwardForkParameter.data, + quantize=False, + pass_list=[PropagateViewCopyPermuteDownPass], + pass_functions=[check], + ) + pipeline.run() + + +class SharedDownwardParameter(torch.nn.Module): + def __init__(self) -> None: + super().__init__() + self.weight = torch.nn.Parameter(torch.randn(2)) + + def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: + return x.permute(0, 2, 3, 1) - self.weight, self.weight + + data = (torch.randn(1, 2, 3, 4),) + + +def test_propagate_down_stops_at_shared_parameter_tosa_FP() -> None: + def predicate(targets: list[object]) -> None: + assert targets.index(PERMUTE) < targets.index(SUB) + + pipeline = PassPipeline[input_t]( + SharedDownwardParameter(), + SharedDownwardParameter.data, + quantize=False, + pass_list=[PropagateViewCopyPermuteDownPass], pass_functions=[_assert_call_targets(predicate)], ) pipeline.run() +class MutableDownwardBuffer(torch.nn.Module): + offset: torch.Tensor + + def __init__(self) -> None: + super().__init__() + self.register_buffer("offset", torch.randn(2)) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + self.offset.add_(1.0) + return x.permute(0, 2, 3, 1) - self.offset + + data = (torch.randn(1, 2, 3, 4),) + + +def test_propagate_down_stops_at_mutable_buffer_tosa_FP() -> None: + def predicate(targets: list[object]) -> None: + assert targets.index(PERMUTE) < targets.index(SUB) + + def check_mutation(exported_program: ExportedProgram) -> ExportedProgram: + assert exported_program.graph_signature.buffers_to_mutate + return exported_program + + pipeline = PassPipeline[input_t]( + MutableDownwardBuffer(), + MutableDownwardBuffer.data, + quantize=False, + pass_list=[PropagateViewCopyPermuteDownPass], + pass_functions=[_assert_call_targets(predicate), check_mutation], + ) + pipeline.run() + + def _run_pass_on_graph_module( graph: torch.fx.Graph, pass_cls: type[ArmPass] = PropagateViewCopyPermuteUpPass, @@ -954,7 +1361,7 @@ def test_down_pass_keeps_permute_before_multiple_exit_pointwise_region() -> None assert targets.index(PERMUTE) < targets.index(FLOOR) < targets.index(SUB) -def test_down_pass_keeps_lower_rank_singleton_input_before_binary_op() -> None: +def test_down_pass_moves_past_lower_rank_singleton_input() -> None: graph = torch.fx.Graph() x = graph.placeholder("x") x.meta["val"] = torch.empty((1, 2, 3, 4)) @@ -968,7 +1375,120 @@ def test_down_pass_keeps_lower_rank_singleton_input_before_binary_op() -> None: targets = _run_pass_on_graph(graph, PropagateViewCopyPermuteDownPass) + assert targets.index(ADD) < targets.index(PERMUTE) + + +def test_down_pass_lifts_permute_across_rank_expanding_binary_op() -> None: + graph = torch.fx.Graph() + x = graph.placeholder("x") + x.meta["val"] = torch.empty((2, 3)) + invariant = graph.placeholder("invariant") + invariant.meta["val"] = torch.empty((1, 1, 1, 1)) + permute = graph.call_function(PERMUTE, args=(x, [1, 0])) + permute.meta["val"] = torch.empty((3, 2)) + add = graph.call_function(ADD, args=(permute, invariant)) + add.meta["val"] = torch.empty((1, 1, 3, 2)) + graph.output(add) + + graph_module = _run_pass_on_graph_module(graph, PropagateViewCopyPermuteDownPass) + call_nodes = [ + node for node in graph_module.graph.nodes if node.op == "call_function" + ] + targets = [node.target for node in call_nodes] + moved_permute = next(node for node in call_nodes if node.target == PERMUTE) + + assert targets.index(ADD) < targets.index(PERMUTE) + assert moved_permute.args[1] == [0, 1, 3, 2] + x_data = torch.randn(2, 3) + invariant_data = torch.randn(1, 1, 1, 1) + expected = x_data.permute(1, 0) + invariant_data + torch.testing.assert_close(graph_module(x_data, invariant_data), expected) + + +def test_up_pass_projects_permute_across_rank_expanding_binary_op() -> None: + graph = torch.fx.Graph() + x = graph.placeholder("x") + x.meta["val"] = torch.empty((2, 3)) + invariant = graph.placeholder("invariant") + invariant.meta["val"] = torch.empty((1, 1, 1, 1)) + add = graph.call_function(ADD, args=(x, invariant)) + add.meta["val"] = torch.empty((1, 1, 2, 3)) + permute = graph.call_function(PERMUTE, args=(add, [0, 1, 3, 2])) + permute.meta["val"] = torch.empty((1, 1, 3, 2)) + graph.output(permute) + + graph_module = _run_pass_on_graph_module(graph, PropagateViewCopyPermuteUpPass) + call_nodes = [ + node for node in graph_module.graph.nodes if node.op == "call_function" + ] + targets = [node.target for node in call_nodes] + moved_permute = next(node for node in call_nodes if node.target == PERMUTE) + assert targets.index(PERMUTE) < targets.index(ADD) + assert moved_permute.args[1] == [1, 0] + x_data = torch.randn(2, 3) + invariant_data = torch.randn(1, 1, 1, 1) + expected = (x_data + invariant_data).permute(0, 1, 3, 2) + torch.testing.assert_close(graph_module(x_data, invariant_data), expected) + + +@pytest.mark.parametrize( + "binary_op", + BINARY_OPS, +) +def test_down_pass_moves_past_invariant_input_for_all_binary_ops( + binary_op: Callable[..., object], +) -> None: + graph = torch.fx.Graph() + x = graph.placeholder("x") + x.meta["val"] = torch.empty((1, 2, 3, 4), dtype=torch.int32) + invariant = graph.placeholder("invariant") + invariant.meta["val"] = torch.empty((1, 1), dtype=torch.int32) + permute = graph.call_function(PERMUTE, args=(x, [0, 2, 3, 1])) + permute.meta["val"] = torch.empty((1, 3, 4, 2), dtype=torch.int32) + binary = graph.call_function(binary_op, args=(permute, invariant)) + output_dtype = ( + torch.bool + if binary_op in COMPARISON_OPS + else ( + torch.float32 if binary_op == exir_ops.edge.aten.div.Tensor else torch.int32 + ) + ) + binary.meta["val"] = torch.empty((1, 3, 4, 2), dtype=output_dtype) + graph.output(binary) + + targets = _run_pass_on_graph(graph, PropagateViewCopyPermuteDownPass) + + assert targets.index(binary_op) < targets.index(PERMUTE) + + +@pytest.mark.parametrize("binary_op", BINARY_OPS) +def test_up_pass_crosses_invariant_fan_in_for_all_binary_ops( + binary_op: Callable[..., object], +) -> None: + graph = torch.fx.Graph() + x = graph.placeholder("x") + x.meta["val"] = torch.empty((1, 2, 3, 4), dtype=torch.int32) + invariant = graph.placeholder("invariant") + invariant.meta["val"] = torch.empty((1, 1), dtype=torch.int32) + neg = graph.call_function(NEG, args=(x,)) + neg.meta["val"] = torch.empty((1, 2, 3, 4), dtype=torch.int32) + binary = graph.call_function(binary_op, args=(invariant, neg)) + output_dtype = ( + torch.bool + if binary_op in COMPARISON_OPS + else ( + torch.float32 if binary_op == exir_ops.edge.aten.div.Tensor else torch.int32 + ) + ) + binary.meta["val"] = torch.empty((1, 2, 3, 4), dtype=output_dtype) + permute = graph.call_function(PERMUTE, args=(binary, [0, 2, 3, 1])) + permute.meta["val"] = torch.empty((1, 3, 4, 2), dtype=output_dtype) + graph.output(permute) + + targets = _run_pass_on_graph(graph, PropagateViewCopyPermuteUpPass) + + assert targets.index(PERMUTE) < targets.index(NEG) < targets.index(binary_op) def test_down_pass_keeps_sunk_view_before_rank_reducing_permute() -> None: @@ -1194,6 +1714,44 @@ def test_down_pass_keeps_permutation_before_sum() -> None: assert transform.meta["val"].shape == torch.Size((1, 3, 4, 2)) +@pytest.mark.parametrize("shape", [(3, 3), (2, 3)]) +@pytest.mark.parametrize("mean_first", [False, True]) +@pytest.mark.parametrize("run_up_pass", [False, True]) +@pytest.mark.parametrize("fork", [False, True]) +def test_down_pass_keeps_layout_invariant_frontier_on_its_branch( + shape: tuple[int, int], mean_first: bool, run_up_pass: bool, fork: bool +) -> None: + x_data = torch.arange(shape[0] * shape[1], dtype=torch.float32).reshape(shape) + y_data = x_data + 1 + graph = torch.fx.Graph() + x = graph.placeholder("x") + x.meta["val"] = torch.empty_like(x_data) + y = graph.placeholder("y") + y.meta["val"] = torch.empty_like(y_data) + permute = graph.call_function(PERMUTE, args=(x, [1, 0])) + permute.meta["val"] = torch.empty((shape[1], shape[0])) + mean = graph.call_function(MEAN, args=(permute, [0, 1], True)) + mean.meta["val"] = torch.empty((1, 1)) + add_args = (mean, y) if mean_first else (y, mean) + add = graph.call_function(ADD, args=add_args) + add.meta["val"] = torch.empty_like(y_data) + if fork: + neg = graph.call_function(NEG, args=(mean,)) + neg.meta["val"] = torch.empty((1, 1)) + graph.output((add, neg)) + else: + graph.output(add) + + graph_module = torch.fx.GraphModule(torch.nn.Module(), graph) + expected = graph_module(x_data, y_data) + graph_module = PropagateViewCopyPermuteDownPass().call(graph_module).graph_module + if run_up_pass: + graph_module = PropagateViewCopyPermuteUpPass().call(graph_module).graph_module + + graph_module.graph.lint() + torch.testing.assert_close(graph_module(x_data, y_data), expected) + + @pytest.mark.parametrize("mean_first", [False, True]) def test_down_pass_keeps_permute_before_reduction_with_layout_dependent_user( mean_first: bool, @@ -1655,6 +2213,24 @@ def test_propagate_up_keeps_view_after_table_with_batched_singleton_spatial() -> assert targets.index(TABLE) < targets.index(VIEW) +def test_propagate_up_hoists_view_through_table_for_safe_shapes() -> None: + graph = torch.fx.Graph() + x = graph.placeholder("x") + x.meta["val"] = torch.empty((1, 1, 1, 64), dtype=torch.int8) + table = graph.placeholder("table") + table.meta["val"] = torch.empty((256,), dtype=torch.int8) + table_node = graph.call_function(TABLE, args=(x, table)) + table_node.meta["val"] = torch.empty((1, 1, 1, 64), dtype=torch.int8) + view = graph.call_function(VIEW, args=(table_node, [1, 64])) + view.meta["val"] = torch.empty((1, 64), dtype=torch.int8) + graph.output(view) + + with TosaLoweringContext(TosaSpecification.create_from_string("TOSA-1.0+INT")): + targets = _run_pass_on_graph(graph, PropagateViewCopyPermuteUpPass) + + assert targets.index(VIEW) < targets.index(TABLE) + + def test_propagate_down_still_sinks_view_through_table_for_safe_shapes() -> None: """The guard is limited to [N, 1, 1, C] with N > 1; other views sink.""" graph = torch.fx.Graph() diff --git a/backends/arm/test/targets.bzl b/backends/arm/test/targets.bzl index 76523082601..ee860b8ce88 100644 --- a/backends/arm/test/targets.bzl +++ b/backends/arm/test/targets.bzl @@ -62,6 +62,11 @@ def define_arm_tests(): "ops/test_split.py", ] + # Models + test_files += [ + "models/test_upsample_slice_concat.py", + ] + # Modules test_files += [ "modules/test_moe.py", @@ -202,7 +207,10 @@ def define_arm_tests(): ] if test_file == "misc/test_version_xfail.py" else []) + ([ "//executorch/exir:schema", "//executorch/exir/_serialize:lib", - ] if test_file == "misc/test_diff_pte.py" else []), + ] if test_file == "misc/test_diff_pte.py" else []) + ([ + "//executorch/backends/transforms:fuse_identical_input_transforms_pass", + "//executorch/backends/transforms:utils", + ] if test_file == "passes/test_propagate_permutes_views_pass.py" else []), ) runtime.cxx_test( diff --git a/backends/transforms/propagate_view_copy_permute_pass.py b/backends/transforms/propagate_view_copy_permute_pass.py index 9258ef43cd2..9b2b35181a6 100644 --- a/backends/transforms/propagate_view_copy_permute_pass.py +++ b/backends/transforms/propagate_view_copy_permute_pass.py @@ -17,7 +17,12 @@ from executorch.backends.transforms.canonicalize_view_copy_permute_pass import ( CanonicalizeViewCopyPermutePass, ) -from executorch.backends.transforms.dim_maps import _Dim, PermuteMap, ViewMap +from executorch.backends.transforms.dim_maps import ( + _Dim, + _dim_equals, + PermuteMap, + ViewMap, +) from executorch.backends.transforms.fuse_duplicate_users_pass import ( FuseDuplicateUsersPass, ) @@ -25,6 +30,11 @@ FuseIdenticalInputTransformsPass, ) from executorch.backends.transforms.permute_view_meta import refresh_permute_view_meta +from executorch.backends.transforms.utils import ( + get_param_tensor, + is_param_node, + set_param_tensor, +) from executorch.exir import ExportedProgram from executorch.exir.dialects._ops import ops as exir_ops from executorch.exir.pass_base import ExportPass, PassResult @@ -32,6 +42,33 @@ from torch.fx.node import map_arg +def _constant_value_for_permutation( + exported_program: ExportedProgram | None, + node: torch.fx.Node, + rank: int, + *, + dims: Sequence[int] | None = None, +) -> torch.Tensor | None: + """Helper function for applying dims permutation to the constant node and returning the result.""" + if exported_program is None: + return None + graph_signature = exported_program.graph_signature + buffer_target = graph_signature.inputs_to_buffers.get(node.name) + if buffer_target is not None and buffer_target in ( + graph_signature.buffers_to_mutate.values() + ): + return None + if not is_param_node(exported_program, node): + return None + value = get_param_tensor(exported_program, node) + if value is None or value.dim() > rank: + return None + if dims is None: + return value + padded = value.reshape((1,) * (rank - value.dim()) + tuple(value.shape)) + return padded.permute(tuple(dims)).contiguous() + + @dataclass(frozen=True) class _ForkBranchSplit: next_node: torch.fx.Node @@ -231,6 +268,14 @@ def _advance_through_next_node( if swapped_args is None: return False + crossing_permute_dims = ( + cast(Sequence[int], swapped_args[0][1]) + if node.target in self._permute_targets + and next_node.target + in FuseIdenticalInputTransformsPass._BINARY_ELEMENTWISE_OPS + else None + ) + self._maybe_update_constant_inputs(next_node, node, crossing_permute_dims) node.args = swapped_args[0] next_node.args = swapped_args[1] return True @@ -425,10 +470,89 @@ def is_transparent(self, node: torch.fx.Node) -> bool: """ return node.target in self._TRANSPARENT_TARGETS - def is_multi_input_elementwise(self, node: torch.fx.Node) -> bool: - """Elementwise ops whose extra inputs are not layout-carrying.""" + def is_permutable_binary(self, node: torch.fx.Node) -> bool: + """Whether a binary op has exactly one layout-carrying input.""" + if node.target not in FuseIdenticalInputTransformsPass._BINARY_ELEMENTWISE_OPS: + return False + value = node.meta.get("val") + return ( + isinstance(value, torch.Tensor) + and len(self._layout_dependent_inputs(node, len(value.shape))) == 1 + ) + + def is_backend_elementwise(self, node: torch.fx.Node) -> bool: + """Whether a backend op can be propagated through as elementwise.""" return False + def _layout_dependent_inputs( + self, node: torch.fx.Node, rank: int + ) -> list[torch.fx.Node]: + allow_constants = ( + node.target in FuseIdenticalInputTransformsPass._BINARY_ELEMENTWISE_OPS + ) + return [ + input_node + for input_node in node.all_input_nodes + if not self._is_layout_invariant(input_node, rank) + and not ( + allow_constants + and len(input_node.users) == 1 + and _constant_value_for_permutation( + self.exported_program, input_node, rank + ) + is not None + ) + ] + + @staticmethod + def _is_layout_invariant(node: torch.fx.Node, rank: int) -> bool: + value = node.meta.get("val") + return ( + isinstance(value, torch.Tensor) + and len(value.shape) <= rank + and all(_dim_equals(dim, 1) for dim in value.shape) + ) + + def _maybe_update_constant_inputs( + self, + node: torch.fx.Node, + moving_node: torch.fx.Node, + crossing_permute_dims: Sequence[int] | None = None, + ) -> None: + if ( + node.target not in FuseIdenticalInputTransformsPass._BINARY_ELEMENTWISE_OPS + or moving_node.target not in self._permute_targets + ): + return + permute_map = PermuteMap(moving_node) + dims = self._constant_permutation_dims(permute_map, crossing_permute_dims) + rank = len(dims) + layout_dependent_inputs = set(self._layout_dependent_inputs(node, rank)) + for input_node in node.all_input_nodes: + if input_node in layout_dependent_inputs or self._is_layout_invariant( + input_node, rank + ): + continue + value = _constant_value_for_permutation( + self.exported_program, + input_node, + rank, + dims=dims, + ) + assert value is not None + assert self.exported_program is not None + set_param_tensor(self.exported_program, input_node, value) + input_node.meta = copy.copy(input_node.meta) + input_node.meta["val"] = input_node.meta["val"].new_empty(value.shape) + + @abstractmethod + def _constant_permutation_dims( + self, + permute_map: PermuteMap, + crossing_permute_dims: Sequence[int] | None, + ) -> Sequence[int]: + pass + def blocks_crossing(self, node: torch.fx.Node) -> bool: """Users past which a frontier node must not be moved.""" return False @@ -469,9 +593,16 @@ def is_elementwise(self, node: torch.fx.Node) -> bool: return torch.Tag.pointwise in op.tags return False + def _is_elementwise_like(self, node: torch.fx.Node) -> bool: + return ( + self.is_elementwise(node) + or node.target in FuseIdenticalInputTransformsPass._BINARY_ELEMENTWISE_OPS + or self.is_backend_elementwise(node) + ) + def is_swappable(self, next_node: torch.fx.Node) -> bool: return ( - self.is_elementwise(next_node) + self._is_elementwise_like(next_node) or next_node.target in self._ARG_UPDATE_TARGETS ) @@ -480,20 +611,28 @@ def _can_move_through_elementwise( moving_node: torch.fx.Node, next_node: torch.fx.Node, ) -> bool: - """Return whether ``moving_node`` can cross an elementwise operation. + """Return whether ``moving_node`` can cross an elementwise-like operation.""" - Only simple single-input, single-output operations are handled here. - Multi-input elementwise operations are handled by horizontal fusion. + input_count = len(next_node.all_input_nodes) + next_val = next_node.meta.get("val") + is_registered_binary = ( + next_node.target in FuseIdenticalInputTransformsPass._BINARY_ELEMENTWISE_OPS + ) + is_unary_elementwise = ( + not is_registered_binary + and input_count == 1 + and self.is_elementwise(next_node) + ) + is_permutable_binary = ( + moving_node.target in self._permute_targets + and is_registered_binary + and self.is_permutable_binary(next_node) + ) + is_backend_elementwise = self.is_backend_elementwise(next_node) + if not (is_unary_elementwise or is_permutable_binary or is_backend_elementwise): + return False - """ - if ( - not self.is_elementwise(next_node) - or ( - len(next_node.all_input_nodes) != 1 - and not self.is_multi_input_elementwise(next_node) - ) - or not isinstance(next_node.meta.get("val"), torch.Tensor) - ): + if not isinstance(next_val, torch.Tensor): return False if moving_node.target == self._VIEW_TARGET: @@ -514,13 +653,12 @@ def _can_move_through_elementwise( if moving_node.target not in self._permute_targets: return True - dims = self._dim_arg(moving_node.args[1]) - source_rank = len(dims) if isinstance(dims, Sequence) else None - next_val = next_node.meta.get("val") - return ( - source_rank is not None - and isinstance(next_val, torch.Tensor) - and len(next_val.shape) == source_rank + if is_permutable_binary: + return True + + permute_dims = self._dim_arg(moving_node.args[1]) + return isinstance(permute_dims, Sequence) and len(next_val.shape) == len( + permute_dims ) @@ -540,22 +678,8 @@ def _shape_seen_by_next_node(self, moving_node: torch.fx.Node) -> Any: return getattr(val, "shape", None) def _propagation_input(self, node: torch.fx.Node) -> torch.fx.Node | None: - inputs = list(node.all_input_nodes) - if len(inputs) > 1 and not self.is_multi_input_elementwise(node): - value = node.meta.get("val") - if not isinstance(value, torch.Tensor): - return None - rank = len(value.shape) - inputs = [ - input_node - for input_node in inputs - if not FuseIdenticalInputTransformsPass.is_layout_invariant( - input_node, rank - ) - ] - if inputs and (len(inputs) == 1 or self.is_multi_input_elementwise(node)): - return inputs[0] - return None + inputs = list(self._get_next_nodes(node)) + return inputs[0] if len(inputs) == 1 else None def _refresh_propagation_meta(self, moving_node, propagation_path) -> None: # After moving up, dependencies run from the transform through the @@ -566,6 +690,13 @@ def _refresh_propagation_meta(self, moving_node, propagation_path) -> None: if node.graph is not None: self._refresh_node_meta(node) + def _constant_permutation_dims( + self, + permute_map: PermuteMap, + crossing_permute_dims: Sequence[int] | None, + ) -> Sequence[int]: + return permute_map.permute_dims + def fuse_horizontal(self, graph_module): modified = False result = FuseDuplicateUsersPass(self.duplicate_user_fusion_exclusions()).call( @@ -576,7 +707,15 @@ def fuse_horizontal(self, graph_module): return PassResult(graph_module, modified) def _get_next_nodes(self, node: torch.fx.Node) -> Iterable[torch.fx.Node]: - return list(node.all_input_nodes) + input_nodes = list(node.all_input_nodes) + value = node.meta.get("val") + if len(input_nodes) > 1 and isinstance(value, torch.Tensor): + layout_dependent_inputs = self._layout_dependent_inputs( + node, len(value.shape) + ) + if len(layout_dependent_inputs) == 1: + return layout_dependent_inputs + return input_nodes def _get_prev_nodes(self, node: torch.fx.Node) -> Iterable[torch.fx.Node]: return list(node.users.keys()) @@ -598,8 +737,11 @@ def _can_move_through_elementwise( ) -> bool: if super()._can_move_through_elementwise(moving_node, next_node): return True - if moving_node.target not in self._permute_targets or not self.is_elementwise( - next_node + if ( + moving_node.target not in self._permute_targets + or next_node.target + in FuseIdenticalInputTransformsPass._BINARY_ELEMENTWISE_OPS + or not self.is_elementwise(next_node) ): return False @@ -619,7 +761,7 @@ def _can_move_through_elementwise( def _maybe_swap_permute_args( self, node: torch.fx.Node, next_node: torch.fx.Node ) -> Any | None: - if self.is_elementwise(next_node): + if self._is_elementwise_like(next_node): if not self._can_move_through_elementwise(node, next_node): return None next_val = next_node.meta.get("val") @@ -679,7 +821,7 @@ def _maybe_swap_permute_args( def _maybe_swap_view_args( self, node: torch.fx.Node, next_node: torch.fx.Node ) -> Any | None: - if self.is_elementwise(next_node): + if self._is_elementwise_like(next_node): if not self._can_move_through_elementwise(node, next_node): return None return node.args, next_node.args @@ -918,6 +1060,21 @@ def _refresh_propagation_meta(self, moving_node, propagation_path) -> None: if moving_node.graph is not None: self._refresh_node_meta(moving_node) + def _constant_permutation_dims( + self, + permute_map: PermuteMap, + crossing_permute_dims: Sequence[int] | None, + ) -> Sequence[int]: + dims = ( + crossing_permute_dims + if crossing_permute_dims is not None + else permute_map.permute_dims + ) + inverse_dims = [0] * len(dims) + for output_dim, input_dim in enumerate(dims): + inverse_dims[input_dim] = output_dim + return inverse_dims + def fuse_horizontal(self, graph_module): modified = False result = FuseIdenticalInputTransformsPass( @@ -933,10 +1090,20 @@ def _get_next_nodes(self, node: torch.fx.Node) -> Iterable[torch.fx.Node]: def _get_prev_nodes(self, node: torch.fx.Node) -> Iterable[torch.fx.Node]: return list(node.all_input_nodes) + def _can_cross_next_nodes( + self, frontier: torch.fx.Node, next_nodes: Sequence[torch.fx.Node] + ) -> bool: + for next_node in next_nodes: + if self.is_permutable_binary(next_node): + rank = len(next_node.meta["val"].shape) + if self._layout_dependent_inputs(next_node, rank) != [frontier]: + return False + return True + def _maybe_swap_permute_args( self, node: torch.fx.Node, next_node: torch.fx.Node ) -> Any | None: - if self.is_elementwise(next_node): + if self._is_elementwise_like(next_node): if not self._can_move_through_elementwise(node, next_node): return None next_val = next_node.meta.get("val") @@ -982,7 +1149,7 @@ def _maybe_swap_permute_args( return (node_args, (*next_node.args[:1], new_args, *next_node.args[2:])) def _maybe_swap_view_args(self, node, next_node): - if self.is_elementwise(next_node): + if self._is_elementwise_like(next_node): if not self._can_move_through_elementwise(node, next_node): return None return node.args, next_node.args @@ -1272,9 +1439,9 @@ def _plan_fork_split( Fork propagation is only implemented for permutes. The permutation must be valid, every branch must support the same propagation step, and every branch - output must retain the permutation rank. Planning all branches before editing - is essential: discovering one unsupported branch after mutating earlier ones - would leave a partially rewritten graph. + output must match its post-swap permutation rank. Planning all branches + before editing is essential: discovering one unsupported branch after mutating + earlier ones would leave a partially rewritten graph. Each planned branch records both its pre-permute shape and any argument update needed when crossing reductions or slices. @@ -1295,9 +1462,7 @@ def _plan_fork_split( branch_splits = [] for next_node in next_nodes: - if self._can_move_through_elementwise( - node, next_node - ) or self._can_split_through_elementwise(node, frontier, next_node): + if self._can_split_through_elementwise(node, frontier, next_node): arg_update = None elif self.is_swappable(next_node): arg_update = self._maybe_swap_args(node, next_node) @@ -1306,11 +1471,16 @@ def _plan_fork_split( else: return None + branch_dims = normalized_dims if arg_update is None else arg_update[0][1] + branch_rank = len(branch_dims) next_val = next_node.meta.get("val") - if not isinstance(next_val, torch.Tensor) or len(next_val.shape) != rank: + if ( + not isinstance(next_val, torch.Tensor) + or len(next_val.shape) != branch_rank + ): return None - source_shape: list[_Dim] = [1] * rank - for output_axis, source_axis in enumerate(normalized_dims): + source_shape: list[_Dim] = [1] * branch_rank + for output_axis, source_axis in enumerate(branch_dims): source_shape[source_axis] = next_val.shape[output_axis] branch_splits.append( _ForkBranchSplit(next_node, tuple(source_shape), arg_update) @@ -1330,8 +1500,7 @@ def _can_split_through_elementwise( return False rank = len(frontier_val.shape) return all( - input_node is frontier - or FuseIdenticalInputTransformsPass.is_layout_invariant(input_node, rank) + input_node is frontier or self._is_layout_invariant(input_node, rank) for input_node in next_node.all_input_nodes ) @@ -1357,16 +1526,23 @@ def _apply_fork_split( original_user = next(iter(node.users)) original_user.replace_input_with(node, producer) + for branch_split in branch_splits: + crossing_permute_dims = ( + cast(Sequence[int], branch_split.arg_update[0][1]) + if branch_split.arg_update is not None + else None + ) + self._maybe_update_constant_inputs( + branch_split.next_node, node, crossing_permute_dims + ) + for branch_split in branch_splits: next_node = branch_split.next_node old_next_meta = copy.copy(next_node.meta) + if branch_split.arg_update is not None: + next_node.args = branch_split.arg_update[1] if frontier is node: next_node.replace_input_with(node, producer) - if branch_split.arg_update is not None: - branch_input = ( - producer if frontier is node else branch_split.arg_update[1][0] - ) - next_node.args = (branch_input, *branch_split.arg_update[1][1:]) next_node.meta = copy.copy(next_node.meta) next_node.meta["val"] = old_next_meta["val"].new_empty( branch_split.source_shape diff --git a/backends/transforms/targets.bzl b/backends/transforms/targets.bzl index 34fd26739cf..c10cef9d060 100644 --- a/backends/transforms/targets.bzl +++ b/backends/transforms/targets.bzl @@ -365,6 +365,8 @@ def define_common_targets(): ":dim_maps", ":fuse_duplicate_users_pass", ":fuse_identical_input_transforms_pass", + ":permute_view_meta", + ":utils", "//executorch/exir:lib", "//executorch/exir:pass_base", "//executorch/exir/dialects:lib", From 9cec4f6aead23658925350dd4ef11c96ca1050c7 Mon Sep 17 00:00:00 2001 From: Jacob Stevens Date: Wed, 4 Nov 2026 12:35:50 -0500 Subject: [PATCH 054/128] Release packed XNNPACK convolution constants after preparation (#24551) Summary: ## Ulterior Motive Reduce resident memory of convolution-heavy models after load. ## Rationale **What**: Mark XNNPACK convolution and depthwise weights and biases as preparation-only when every use in the region is a packed parameter use, so `CPUPlan::can_release_constant` lets their original buffers be released after preparation. **Why**: Only linear weights and biases were classified this way. Convolution constants are also packed into private storage during preparation, but their original buffers stayed resident for the life of the plan. ## Details - Constants that also feed an activation, an unpacked operation, another provider, or a graph output stay retained. - Release still happens only after every region compiles, the cache finalizes and reshape succeeds. Only original named data buffers are eligible. - Converted-weight allocations, cache, workspace, precision, threading and error propagation are unchanged. - Runtime report tests check release of regular and depthwise convolution constants, alongside packed linear and retained add-parameter controls. - The plan tests use the `XNNLayoutTest` fixture from "cpu: retain NHWC values within XNNPACK regions", without ETDump tracing, so this commit does not depend on that one. Authored with AI using Metamate. Reviewed By: digantdesai Differential Revision: D129436444 Pull Request resolved: https://github.com/pytorch/executorch/pull/24551 --- .../providers/xnnpack/XNNPACKProvider.cpp | 8 +- backends/cpu/runtime/test/CPUPlanTest.cpp | 392 ++++++++++++++++++ backends/cpu/runtime/test/ProviderTest.cpp | 15 + backends/cpu/test/test_runtime.py | 22 +- 4 files changed, 427 insertions(+), 10 deletions(-) diff --git a/backends/cpu/runtime/providers/xnnpack/XNNPACKProvider.cpp b/backends/cpu/runtime/providers/xnnpack/XNNPACKProvider.cpp index 1cdebb2547d..d61b616f839 100644 --- a/backends/cpu/runtime/providers/xnnpack/XNNPACKProvider.cpp +++ b/backends/cpu/runtime/providers/xnnpack/XNNPACKProvider.cpp @@ -1223,12 +1223,16 @@ class XNNImplementation final : public KernelImplementation { continue; } // A constant can also feed an unpacked operation in this region. - const bool packed_use = - node.target == "torch.ops.aten.linear.default" && + bool packed_use = node.target == "torch.ops.aten.linear.default" && arg(node, 0).as_tensor().id != value.id && (arg(node, 1).as_tensor().id == value.id || (arg(node, 2).kind() == ptn::ArgKind::Tensor && arg(node, 2).as_tensor().id == value.id)); + if (const auto convolution = parse_convolution(node)) { + packed_use = convolution->input != value.id && + (convolution->weight == value.id || + convolution->bias == value.id); + } packed |= packed_use; retained |= !packed_use; } diff --git a/backends/cpu/runtime/test/CPUPlanTest.cpp b/backends/cpu/runtime/test/CPUPlanTest.cpp index 6052bd0ccdc..d41fbe6c4e9 100644 --- a/backends/cpu/runtime/test/CPUPlanTest.cpp +++ b/backends/cpu/runtime/test/CPUPlanTest.cpp @@ -15,6 +15,11 @@ #include #include #include +#include +#include +#include +#include +#include namespace executorch::backends::cpu { namespace { @@ -85,6 +90,393 @@ class CPUPlanTest : public ::testing::Test { // Cppcheck cannot expand GTest fixture macros without build headers. // cppcheck-suppress-begin syntaxError +class XNNLayoutTest : public CPUPlanTest { + protected: + ptn::ValueId tensor( + std::vector shape, + ptn::ValueRole role = ptn::ValueRole::Intermediate) { + const auto id = static_cast(graph.values.size()); + graph.values.emplace_back( + std::to_string(id), ptn::ScalarType::Float, std::move(shape)); + graph.values.back().role = role; + if (role == ptn::ValueRole::UserInput) { + graph.input_ids.push_back(id); + } + return id; + } + + ptn::ValueId operation( + const char* target, + std::vector arguments, + std::vector shape) { + const auto output = tensor(std::move(shape)); + ptn::Node node; + node.name = "layout_" + std::to_string(graph.nodes.size()); + node.target = target; + for (auto& argument : arguments) { + node.inputs.push_back({"", std::move(argument)}); + } + node.outputs = {{ptn::OutputValueKind::Tensor, output, {}}}; + graph.nodes.push_back(std::move(node)); + return output; + } + + ptn::ValueId convolution( + ptn::ValueId input, + ptn::ValueId weight, + ptn::ValueId bias, + int64_t groups = 1) { + auto shape = graph.value(input).tensor_meta().sizes; + const auto filter = graph.value(weight).tensor_meta().sizes; + shape[1] = filter[0]; + return operation( + "torch.ops.aten.convolution.default", + {ptn::TensorArg{input}, + ptn::TensorArg{weight}, + bias == ptn::kInvalid ? ptn::Argument(ptn::NoneArg{}) + : ptn::Argument(ptn::TensorArg{bias}), + ptn::IntListArg{{1, 1}, {}}, + ptn::IntListArg{{filter[2] / 2, filter[3] / 2}, {}}, + ptn::IntListArg{{1, 1}, {}}, + ptn::BoolArg{false}, + ptn::IntListArg{{0, 0}, {}}, + ptn::IntArg{groups}}, + shape); + } + + ptn::ValueId activation(const char* target, ptn::ValueId input) { + return operation( + target, + {ptn::TensorArg{input}}, + graph.value(input).tensor_meta().sizes); + } + + ptn::ValueId binary( + const char* target, + ptn::ValueId lhs, + ptn::ValueId rhs, + const std::vector& shape) { + std::vector arguments{ + ptn::TensorArg{lhs}, ptn::TensorArg{rhs}}; + if (std::string_view(target) == "torch.ops.aten.add.Tensor") { + arguments.emplace_back(ptn::IntArg{1}); + } + return operation(target, std::move(arguments), shape); + } + + void check( + std::vector outputs, + const std::vector& providers = {"XNNPACK"}) { + finish(std::move(outputs)); + alignas(128) std::array memory{}; + MemoryAllocator test_allocator(memory.size(), memory.data()); + std::vector reference_buffers(graph.values.size()); + for (size_t id = 0; id < graph.values.size(); ++id) { + const auto role = graph.values[id].role; + if (role != ptn::ValueRole::Parameter && + role != ptn::ValueRole::ConstantTensor && + role != ptn::ValueRole::Buffer) { + continue; + } + const auto bytes = tensor_bytes(graph.values[id]).get(); + auto constant = allocate_buffer(test_allocator, bytes); + auto reference_constant = allocate_buffer(test_allocator, bytes); + ASSERT_TRUE(constant.ok()); + ASSERT_TRUE(reference_constant.ok()); + buffers[id] = constant.get(); + reference_buffers[id] = reference_constant.get(); + auto* data = static_cast(constant->data); + for (size_t index = 0; index < graph.values[id].tensor_meta().numel(); + ++index) { + data[index] = (float((index * 7 + id) % 17) - 8) / 32; + } + std::memcpy(reference_constant->data, constant->data, bytes); + } + CPUPlan plan(graph, buffers, configuration, execution); + auto reference_configuration = configuration; + reference_configuration.preferences = {{"Reference", ""}}; + reference_configuration.force = true; + CPUPlan reference( + graph, reference_buffers, reference_configuration, ExecutionContext{}); + ASSERT_EQ(reference.select(), Error::Ok); + ASSERT_EQ(plan.select(), Error::Ok); + ASSERT_EQ(plan.steps().size(), providers.size()); + for (size_t index = 0; index < providers.size(); ++index) { + EXPECT_EQ(plan.steps()[index].provider->name(), providers[index]); + } + for (auto id : preparation_only_constants) { + EXPECT_FALSE(plan.can_release_constant(id)); + } + ASSERT_EQ(plan.prepare(test_allocator), Error::Ok); + ASSERT_EQ(reference.prepare(test_allocator), Error::Ok); + for (auto id : runtime_constants) { + EXPECT_FALSE(plan.can_release_constant(id)) << id; + } + for (auto id : preparation_only_constants) { + ASSERT_TRUE(plan.can_release_constant(id)) << id; + std::memset(buffers[id].data, 0xFF, buffers[id].readable_bytes); + buffers[id] = {}; + } + auto binding_ids = graph.input_ids; + for (auto id : graph.output_ids) { + const auto role = graph.value(id).role; + if (role != ptn::ValueRole::Parameter && + role != ptn::ValueRole::ConstantTensor && + role != ptn::ValueRole::Buffer) { + binding_ids.push_back(id); + } + } + std::vector originals; + std::vector replacements; + for (auto id : binding_ids) { + originals.push_back(buffers[id]); + auto replacement = + allocate_buffer(test_allocator, tensor_bytes(graph.value(id)).get()); + ASSERT_TRUE(replacement.ok()); + replacements.push_back(replacement.get()); + } + for (int run = 0; run < 3; ++run) { + for (size_t binding = 0; binding < binding_ids.size(); ++binding) { + buffers[binding_ids[binding]] = + run == 1 ? replacements[binding] : originals[binding]; + } + for (size_t input = 0; input < graph.input_ids.size(); ++input) { + const auto id = graph.input_ids[input]; + const auto count = graph.value(id).tensor_meta().numel(); + for (size_t index = 0; index < count; ++index) { + const float value = + (float((index * 13 + input) % 31) - 15) / 8 + float(run) / 4; + static_cast(buffers[id].data)[index] = value; + static_cast(reference_buffers[id].data)[index] = value; + } + } + ASSERT_EQ(plan.execute(execution), Error::Ok); + ASSERT_EQ(reference.execute(ExecutionContext{}), Error::Ok); + for (auto id : graph.output_ids) { + SCOPED_TRACE(id); + EXPECT_TRUE(graph.value(id).tensor_meta().is_contiguous()); + const auto* actual = static_cast(buffers[id].data); + const auto* expected = + static_cast(reference_buffers[id].data); + for (size_t index = 0; index < graph.value(id).tensor_meta().numel(); + ++index) { + EXPECT_NEAR( + actual[index], + expected[index], + 2e-5f * std::max(1.0f, std::abs(expected[index]))) + << index; + } + } + } + for (auto id : runtime_constants) { + const bool required = std::any_of( + plan.steps().begin(), plan.steps().end(), [id](const auto& step) { + return std::any_of( + step.storage.values.begin(), + step.storage.values.end(), + [id](const auto& value) { + return value.id == id && !value.preparation_only; + }); + }); + if (required) { + const auto original = buffers[id]; + buffers[id] = {}; + EXPECT_EQ(plan.execute(execution), Error::InvalidArgument) << id; + buffers[id] = original; + } + } + } + + std::vector preparation_only_constants; + std::vector runtime_constants; +}; + +TEST_F(XNNLayoutTest, ConvolutionPackedConstantsSurvivePoisoningAndRebinding) { + struct Case { + int64_t channels, output_channels, groups, kernel; + }; + const std::array cases{ + {{3, 3, 1, 3}, + {3, 3, 1, 1}, + {3, 3, 3, 3}, + {3, 3, 3, 1}, + {1, 1, 1, 3}, + {4, 4, 2, 3}, + {3, 6, 3, 3}}}; + for (const auto& [channels, output_channels, groups, kernel] : cases) { + for (const bool with_bias : {false, true}) { + SCOPED_TRACE(::testing::PrintToString(std::make_tuple( + channels, output_channels, groups, kernel, with_bias))); + graph = {}; + buffers.clear(); + const auto input = tensor({2, channels, 4, 5}, ptn::ValueRole::UserInput); + const auto weight = tensor( + {output_channels, channels / groups, kernel, kernel}, + ptn::ValueRole::Parameter); + const auto bias = with_bias + ? tensor({output_channels}, ptn::ValueRole::Buffer) + : ptn::kInvalid; + preparation_only_constants = {weight}; + if (with_bias) { + preparation_only_constants.push_back(bias); + } + check({convolution(input, weight, bias, groups)}); + } + } +} + +TEST_F(XNNLayoutTest, ConvolutionSharesPackedWeightsAndBiasAcrossRegions) { + for (const bool depthwise : {false, true}) { + for (const int64_t kernel : {1, 3}) { + SCOPED_TRACE(::testing::PrintToString(std::make_pair(depthwise, kernel))); + graph = {}; + buffers.clear(); + const auto input = tensor({2, 3, 4, 5}, ptn::ValueRole::UserInput); + const auto weight = tensor( + {3, depthwise ? 1 : 3, kernel, kernel}, + ptn::ValueRole::ConstantTensor); + const auto bias = tensor({3}, ptn::ValueRole::Parameter); + const auto first = convolution(input, weight, bias, depthwise ? 3 : 1); + const auto middle = operation( + "torch.ops.aten.gelu.default", + {ptn::TensorArg{first}, ptn::StringArg{"tanh"}}, + {2, 3, 4, 5}); + preparation_only_constants = {weight, bias}; + check( + {first, convolution(middle, weight, bias, depthwise ? 3 : 1)}, + {"XNNPACK", "Reference", "XNNPACK"}); + } + } +} + +TEST_F(XNNLayoutTest, ConvolutionAndLinearSharePackedBias) { + const auto input = tensor({2, 3, 4, 5}, ptn::ValueRole::UserInput); + const auto weight = tensor({3, 3, 3, 3}, ptn::ValueRole::Parameter); + const auto bias = tensor({3}, ptn::ValueRole::ConstantTensor); + const auto first = convolution(input, weight, bias); + const auto linear_weight = tensor({3, 5}, ptn::ValueRole::Parameter); + const auto output = operation( + "torch.ops.aten.linear.default", + {ptn::TensorArg{first}, + ptn::TensorArg{linear_weight}, + ptn::TensorArg{bias}}, + {2, 3, 4, 3}); + preparation_only_constants = {weight, bias, linear_weight}; + check({first, output}); +} + +TEST_F(XNNLayoutTest, ConvolutionRetainsMixedPackedAndUnpackedConsumers) { + for (const std::string use : + {"unary", + "nhwc_binary", + "linear_input", + "conv_input", + "bias_binary", + "bias_linear_input", + "reference", + "output"}) { + SCOPED_TRACE(use); + graph = {}; + buffers.clear(); + const std::vector shape{3, 3, 3, 3}; + const auto input = tensor(shape, ptn::ValueRole::UserInput); + const auto weight = tensor(shape, ptn::ValueRole::Parameter); + const auto bias = tensor({3}, ptn::ValueRole::ConstantTensor); + const auto first = convolution(input, weight, bias); + std::vector outputs{first}; + std::vector providers{"XNNPACK"}; + preparation_only_constants = {bias}; + runtime_constants = {weight}; + if (use == "unary") { + outputs.push_back(activation("torch.ops.aten.relu.default", weight)); + providers.emplace_back("XNNPACK"); + } else if (use == "nhwc_binary") { + outputs.push_back( + binary("torch.ops.aten.add.Tensor", first, weight, shape)); + } else if (use == "linear_input") { + const auto linear_weight = tensor({3, 3}, ptn::ValueRole::Parameter); + preparation_only_constants.push_back(linear_weight); + outputs.push_back(operation( + "torch.ops.aten.linear.default", + {ptn::TensorArg{weight}, + ptn::TensorArg{linear_weight}, + ptn::TensorArg{bias}}, + shape)); + providers.emplace_back("XNNPACK"); + } else if (use == "conv_input") { + outputs.push_back(convolution(weight, weight, bias)); + providers.emplace_back("XNNPACK"); + } else if (use == "bias_binary") { + outputs.push_back( + binary("torch.ops.aten.add.Tensor", first, bias, shape)); + preparation_only_constants = {weight}; + runtime_constants = {bias}; + } else if (use == "bias_linear_input") { + const auto linear_weight = tensor({3, 3}, ptn::ValueRole::Parameter); + outputs.push_back(operation( + "torch.ops.aten.linear.default", + {ptn::TensorArg{bias}, + ptn::TensorArg{linear_weight}, + ptn::TensorArg{bias}}, + {3})); + providers.emplace_back("XNNPACK"); + preparation_only_constants = {weight, linear_weight}; + runtime_constants = {bias}; + } else if (use == "reference") { + for (auto id : {weight, bias}) { + outputs.push_back(operation( + "torch.ops.aten.gelu.default", + {ptn::TensorArg{id}, ptn::StringArg{"tanh"}}, + graph.value(id).tensor_meta().sizes)); + providers.emplace_back("Reference"); + } + preparation_only_constants.clear(); + runtime_constants = {weight, bias}; + } else { + outputs.push_back(weight); + outputs.push_back(bias); + preparation_only_constants.clear(); + runtime_constants = {weight, bias}; + } + check(outputs, providers); + } +} + +TEST_F(XNNLayoutTest, DepthwiseRetainsWeightUsedByPrivateBroadcastTranspose) { + const auto input = tensor({3, 3, 3, 3}, ptn::ValueRole::UserInput); + const auto weight = tensor({3, 1, 3, 3}, ptn::ValueRole::Parameter); + const auto bias = tensor({3}, ptn::ValueRole::ConstantTensor); + const auto first = convolution(input, weight, bias, 3); + const auto output = + binary("torch.ops.aten.mul.Tensor", first, weight, {3, 3, 3, 3}); + const auto last = convolution(output, weight, bias, 3); + preparation_only_constants = {bias}; + runtime_constants = {weight}; + check({first, last}); +} + +TEST_F(XNNLayoutTest, DepthwiseRetainsSharedBiasAndConstantOutputs) { + for (const bool expose_constants : {false, true}) { + SCOPED_TRACE(expose_constants); + graph = {}; + buffers.clear(); + const std::vector shape{2, 3, 4, 3}; + const auto input = tensor(shape, ptn::ValueRole::UserInput); + const auto weight = tensor({3, 1, 3, 3}, ptn::ValueRole::Parameter); + const auto bias = tensor({3}, ptn::ValueRole::ConstantTensor); + const auto first = convolution(input, weight, bias, 3); + if (expose_constants) { + preparation_only_constants.clear(); + runtime_constants = {weight, bias}; + check({first, weight, bias}); + } else { + preparation_only_constants = {weight}; + runtime_constants = {bias}; + check({binary("torch.ops.aten.add.Tensor", first, bias, shape)}); + } + } +} + TEST_F(CPUPlanTest, MaxPool2dPreservesReferenceFallbackAndSkipsUnusedIndices) { graph.values.emplace_back( "input", ptn::ScalarType::Float, std::vector{1, 1, 4, 4}); diff --git a/backends/cpu/runtime/test/ProviderTest.cpp b/backends/cpu/runtime/test/ProviderTest.cpp index ba31df1ec46..43438867f7c 100644 --- a/backends/cpu/runtime/test/ProviderTest.cpp +++ b/backends/cpu/runtime/test/ProviderTest.cpp @@ -339,6 +339,12 @@ TEST_F(ProviderTest, XNNPACKSharesWeightConversionsAcrossRegions) { PreparationContext context{ graph, buffers, allocator, private_bytes, execution_context}; auto provider = create_xnnpack_provider(); + auto requirements = provider->implementations()[0]->requirements( + {{0, 1}, {0, 1}, {2, 3}}, graph, execution_context); + ASSERT_TRUE(requirements.ok()); + for (const auto& value : requirements->values) { + EXPECT_EQ(value.preparation_only, value.id == 1); + } auto first = provider->implementations()[0]->compile({{0}, {0, 1}, {2}}, context); ASSERT_TRUE(first.ok()); @@ -350,6 +356,11 @@ TEST_F(ProviderTest, XNNPACKSharesWeightConversionsAcrossRegions) { ASSERT_EQ(provider->finish(), Error::Ok); for (auto* executable : {first.get().get(), second.get().get()}) { ASSERT_EQ(executable->reshape(), Error::Ok); + } + const auto original_weight = buffers[1]; + std::memset(buffers[1].data, 0xFF, buffers[1].readable_bytes); + buffers[1] = {}; + for (auto* executable : {first.get().get(), second.get().get()}) { ASSERT_EQ(executable->bind(), Error::Ok); ASSERT_EQ(executable->run(execution_context), Error::Ok); } @@ -362,6 +373,8 @@ TEST_F(ProviderTest, XNNPACKSharesWeightConversionsAcrossRegions) { graph.values[1] = ptn::Value("weight", ptn::ScalarType::Float, {2, 2, 1, 1}); graph.values[1].role = ptn::ValueRole::Parameter; graph.nodes[0].inputs[4].arg = ptn::IntListArg{{0, 0}, {}}; + buffers[1] = original_weight; + std::fill_n(static_cast(buffers[1].data), 4, 2); private_bytes = 0; provider = create_xnnpack_provider(); auto compatible = @@ -370,6 +383,8 @@ TEST_F(ProviderTest, XNNPACKSharesWeightConversionsAcrossRegions) { EXPECT_EQ(private_bytes, 0); ASSERT_EQ(provider->finish(), Error::Ok); ASSERT_EQ(compatible.get()->reshape(), Error::Ok); + std::memset(buffers[1].data, 0xFF, buffers[1].readable_bytes); + buffers[1] = {}; ASSERT_EQ(compatible.get()->bind(), Error::Ok); ASSERT_EQ(compatible.get()->run(execution_context), Error::Ok); EXPECT_FLOAT_EQ(static_cast(buffers[2].data)[0], 4); diff --git a/backends/cpu/test/test_runtime.py b/backends/cpu/test/test_runtime.py index 7359fc54b3f..11c676bb688 100644 --- a/backends/cpu/test/test_runtime.py +++ b/backends/cpu/test/test_runtime.py @@ -270,17 +270,23 @@ def __init__(self): def forward(self, x): return x + self.p - linear = torch.nn.Linear(16, 8) - # Named constants carry a 64-byte readable tail. - packed_bytes = sum( - t.numel() * t.element_size() + 64 for t in (linear.weight, linear.bias) + cases = ( + (torch.nn.Linear(16, 8), (torch.randn(2, 16),), True), + (torch.nn.Conv2d(3, 4, 3), (torch.randn(1, 3, 8, 8),), True), + (torch.nn.Conv2d(3, 3, 3, groups=3), (torch.randn(1, 3, 8, 8),), True), + (AddParameter(), (torch.randn(2, 16),), False), ) - inputs = (torch.randn(2, 16),) - for model, released in ((linear, packed_bytes), (AddParameter(), 0)): - with self.subTest(model=type(model).__name__): + for model, inputs, packed in cases: + with self.subTest(model=type(model).__name__, packed=packed): model.eval() + # Named constants carry a 64-byte readable tail. + released = ( + sum(t.numel() * t.element_size() + 64 for t in model.parameters()) + if packed + else 0 + ) - def run(model=model): + def run(model=model, inputs=inputs): with self._lower(model, inputs) as runtime: return runtime.forward(inputs)[0] From 4654b5732b85d1b74eeeadc94ca2f0878be5798b Mon Sep 17 00:00:00 2001 From: Jacob Stevens Date: Wed, 4 Nov 2026 12:36:00 -0500 Subject: [PATCH 055/128] Free XNNPACK convolution weight conversions after packing (#24552) Summary: ## Ulterior Motive Reduce resident memory of convolution-heavy models after load. ## Rationale **What**: Allocate converted XNNPACK convolution weights from a malloc-backed allocator owned by the provider and free them in `XNNProvider::finish()` once the weights cache is finalized. `CPUPlan` moves the freed bytes from `private_constant_bytes` to a new `released_private_constant_bytes` report field. **Why**: Every KxK convolution with more than one input channel per group, and every KxK depthwise convolution with more than one channel, gets a copy of its weight in XNNPACK's layout. XNNPACK reads that copy only while it creates runtimes, but the copy came from the method allocator, which cannot free individual allocations, so it stayed resident for the life of the plan. The XNNPACK delegate already frees its compile-time temporaries as soon as its runtime exists. ## Details - Copies are freed after every region compiles and the weights cache is finalized, before any executable is reshaped. XNNPACK's convolution reshape and setup read only the packed weights. - Conversions are still shared across regions until then, and weights whose layout already matches are still borrowed. - The layer-norm epsilon stays in the method allocator because XNNPACK reads it on every run. - `KernelProvider::released_private_constant_bytes()` defaults to 0, so other providers are unchanged. - `cpu_xnnpack` now depends on the header-only `extension/memory_allocator:malloc_memory_allocator`. - By parameter count, ResNet-18's converted 3x3 and 7x7 weights total about 44 MB. Authored with AI using Claude Code. Reviewed By: digantdesai Differential Revision: D129436476 Pull Request resolved: https://github.com/pytorch/executorch/pull/24552 --- backends/cpu/README.md | 5 ++-- backends/cpu/runtime/CPUPlan.cpp | 5 ++++ backends/cpu/runtime/CPUPlan.h | 4 +++ backends/cpu/runtime/KernelProvider.h | 5 ++++ .../cpu/runtime/providers/xnnpack/README.md | 5 ++-- .../providers/xnnpack/XNNPACKProvider.cpp | 27 +++++++++++++++++-- backends/cpu/runtime/test/ProviderTest.cpp | 4 +++ backends/cpu/targets.bzl | 1 + backends/cpu/test/test_runtime.py | 15 +++++++++++ 9 files changed, 65 insertions(+), 6 deletions(-) diff --git a/backends/cpu/README.md b/backends/cpu/README.md index 7adb9b0cb70..6707047db1e 100644 --- a/backends/cpu/README.md +++ b/backends/cpu/README.md @@ -51,8 +51,9 @@ separately from the loader's Native graph and current buffer bindings. Providers receive Native metadata and explicit buffer extents; Reference creates its boxed call frames privately. Requirements are queried before constant preparation and arena allocation. Sequential steps share the maximum declared scratch capacity. XNNPACK -shares compatible convolution-weight conversions within a plan and borrows weights -whose physical order already matches. +shares compatible convolution-weight conversions within a plan, frees them in +`finish()` after all regions compile and the weights cache is finalized, and borrows +weights whose physical order already matches. For a loaded method, shapes, tensor dtypes, contiguous layout, threadpool identity, thread count and ISA capabilities remain fixed. Input/output pointers may change diff --git a/backends/cpu/runtime/CPUPlan.cpp b/backends/cpu/runtime/CPUPlan.cpp index 82db56d014e..480bbeb204d 100644 --- a/backends/cpu/runtime/CPUPlan.cpp +++ b/backends/cpu/runtime/CPUPlan.cpp @@ -104,6 +104,9 @@ Error CPUPlan::prepare(MemoryAllocator& allocator) { if (error != Error::Ok) { return error; } + const auto released = provider->released_private_constant_bytes(); + private_constant_bytes_ -= released; + released_private_constant_bytes_ += released; } for (auto& step : steps_) { error = step.executable->reshape(); @@ -603,6 +606,8 @@ std::string CPUPlan::describe(double preparation_ms) const { ",\"preparation_ms\":" + std::to_string(preparation_ms) + ",\"activation_arena_bytes\":" + std::to_string(arena_bytes_) + ",\"private_constant_bytes\":" + std::to_string(private_constant_bytes_) + + ",\"released_private_constant_bytes\":" + + std::to_string(released_private_constant_bytes_) + ",\"shared_scratch_bytes\":" + std::to_string(scratch_.readable_bytes) + ",\"xnn_workspace_bytes\":null,\"xnn_packed_cache_bytes\":null," "\"xnn_workspace_shared\":true,\"xnn_packed_cache\":\"per_plan\"," diff --git a/backends/cpu/runtime/CPUPlan.h b/backends/cpu/runtime/CPUPlan.h index 76d4dbf86b3..2ec2fa2188b 100644 --- a/backends/cpu/runtime/CPUPlan.h +++ b/backends/cpu/runtime/CPUPlan.h @@ -60,6 +60,9 @@ class CPUPlan { size_t private_constant_bytes() const { return private_constant_bytes_; } + size_t released_private_constant_bytes() const { + return released_private_constant_bytes_; + } private: runtime::Error allocate_activations(runtime::MemoryAllocator& allocator); @@ -75,6 +78,7 @@ class CPUPlan { Buffer scratch_; size_t arena_bytes_ = 0; size_t private_constant_bytes_ = 0; + size_t released_private_constant_bytes_ = 0; bool prepared_ = false; bool preparation_started_ = false; }; diff --git a/backends/cpu/runtime/KernelProvider.h b/backends/cpu/runtime/KernelProvider.h index 9ada789d73a..8497c1f8439 100644 --- a/backends/cpu/runtime/KernelProvider.h +++ b/backends/cpu/runtime/KernelProvider.h @@ -161,6 +161,11 @@ class KernelProvider { virtual runtime::Error finish() { return runtime::Error::Ok; } + /// Cumulative bytes charged to PreparationContext::private_constant_bytes + /// and freed by finish(). Queried after finish() succeeds, once per plan. + virtual size_t released_private_constant_bytes() const { + return 0; + } }; using ProviderFactory = std::unique_ptr (*)(); diff --git a/backends/cpu/runtime/providers/xnnpack/README.md b/backends/cpu/runtime/providers/xnnpack/README.md index 0c1ba17b4fb..3052c9a19da 100644 --- a/backends/cpu/runtime/providers/xnnpack/README.md +++ b/backends/cpu/runtime/providers/xnnpack/README.md @@ -6,8 +6,9 @@ with one implementation, `XNNPACK/subgraph`. Each supported target is one `kOps` entry that pairs its support check with its subgraph lowering. The provider shares a workspace and weights cache across its regions. Compatible -convolution-weight conversions are reused within a plan; weights whose physical -order already matches are borrowed. Region boundaries use buffers managed by +convolution-weight conversions are reused within a plan and freed once the +weights cache is finalized; weights whose physical order already matches are +borrowed. Region boundaries use buffers managed by [`CPUPlan`](../../CPUPlan.h). The `cpu_xnnpack` target in [`targets.bzl`](../../../targets.bzl) exports diff --git a/backends/cpu/runtime/providers/xnnpack/XNNPACKProvider.cpp b/backends/cpu/runtime/providers/xnnpack/XNNPACKProvider.cpp index d61b616f839..d8c366307e6 100644 --- a/backends/cpu/runtime/providers/xnnpack/XNNPACKProvider.cpp +++ b/backends/cpu/runtime/providers/xnnpack/XNNPACKProvider.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include @@ -16,6 +17,7 @@ #include #include #include +#include namespace executorch::backends::cpu { namespace { @@ -228,9 +230,26 @@ class XNNState { CPU_XNN_CHECK(xnn_create_workspace(&workspace)); return Error::Ok; } + // Runtime creation packs these copies and never reads them again. + Result allocate_packing_source(size_t bytes) { + auto storage = allocate_buffer(packing_sources_, bytes); + if (storage.ok()) { + packing_source_bytes_ += storage->readable_bytes; + } + return storage; + } + size_t release_packing_sources() { + converted_weights.clear(); + packing_sources_.reset(); + return std::exchange(packing_source_bytes_, 0); + } std::map, Buffer> converted_weights; xnn_workspace_t workspace = nullptr; xnn_weights_cache_t cache = nullptr; + + private: + extension::MallocMemoryAllocator packing_sources_; + size_t packing_source_bytes_ = 0; }; class XNNExecutable final : public Executable { @@ -470,8 +489,7 @@ class XNNExecutable final : public Executable { if (cached != state_.converted_weights.end()) { packed = static_cast(cached->second.data); } else { - auto storage = allocate_buffer( - context_.allocator, + auto storage = state_.allocate_packing_source( tensor_bytes(context_.graph.value(weight)).get()); if (!storage.ok()) { return storage.error(); @@ -1274,12 +1292,17 @@ class XNNProvider final : public KernelProvider { CPU_XNN_CHECK(xnn_finalize_weights_cache( state_.cache, xnn_weights_cache_finalization_kind_hard)); } + released_private_constant_bytes_ += state_.release_packing_sources(); return Error::Ok; } + size_t released_private_constant_bytes() const override { + return released_private_constant_bytes_; + } private: XNNState state_; XNNImplementation implementation_; + size_t released_private_constant_bytes_ = 0; }; } // namespace // Referenced by generated provider registries and provider tests. diff --git a/backends/cpu/runtime/test/ProviderTest.cpp b/backends/cpu/runtime/test/ProviderTest.cpp index 43438867f7c..63badc27c4a 100644 --- a/backends/cpu/runtime/test/ProviderTest.cpp +++ b/backends/cpu/runtime/test/ProviderTest.cpp @@ -354,6 +354,9 @@ TEST_F(ProviderTest, XNNPACKSharesWeightConversionsAcrossRegions) { ASSERT_TRUE(second.ok()); EXPECT_EQ(private_bytes, 36 * sizeof(float) + kReadableTail); ASSERT_EQ(provider->finish(), Error::Ok); + EXPECT_EQ( + provider->released_private_constant_bytes(), + 36 * sizeof(float) + kReadableTail); for (auto* executable : {first.get().get(), second.get().get()}) { ASSERT_EQ(executable->reshape(), Error::Ok); } @@ -382,6 +385,7 @@ TEST_F(ProviderTest, XNNPACKSharesWeightConversionsAcrossRegions) { ASSERT_TRUE(compatible.ok()); EXPECT_EQ(private_bytes, 0); ASSERT_EQ(provider->finish(), Error::Ok); + EXPECT_EQ(provider->released_private_constant_bytes(), 0); ASSERT_EQ(compatible.get()->reshape(), Error::Ok); std::memset(buffers[1].data, 0xFF, buffers[1].readable_bytes); buffers[1] = {}; diff --git a/backends/cpu/targets.bzl b/backends/cpu/targets.bzl index 6832e6f1201..32455d2de2b 100644 --- a/backends/cpu/targets.bzl +++ b/backends/cpu/targets.bzl @@ -47,6 +47,7 @@ def define_common_targets(): exported_headers = ["runtime/providers/xnnpack/XNNPACKProvider.h"], exported_deps = [":cpu_runtime"], deps = [ + "//executorch/extension/memory_allocator:malloc_memory_allocator", "//executorch/extension/threadpool:threadpool", third_party_dep("XNNPACK"), ], diff --git a/backends/cpu/test/test_runtime.py b/backends/cpu/test/test_runtime.py index 11c676bb688..45c3bce149e 100644 --- a/backends/cpu/test/test_runtime.py +++ b/backends/cpu/test/test_runtime.py @@ -294,3 +294,18 @@ def run(model=model, inputs=inputs): report = _plan_report(log) self.assertEqual(report["released_constant_bytes"], released) torch.testing.assert_close(actual, model(*inputs), rtol=1e-5, atol=1e-5) + + def test_plan_report_counts_released_weight_conversions(self): + model = torch.nn.Conv2d(3, 4, 3).eval() + inputs = (torch.randn(1, 3, 8, 8),) + + def run(): + with self._lower(model, inputs) as runtime: + return runtime.forward(inputs)[0] + + actual, log = _run_capturing_stderr(run) + report = _plan_report(log) + # The 4x3x3x3 weight and its readable tail, converted to XNNPACK's layout. + self.assertEqual(report["released_private_constant_bytes"], 432 + 64) + self.assertEqual(report["private_constant_bytes"], 0) + torch.testing.assert_close(actual, model(*inputs), rtol=1e-5, atol=1e-5) From c7f7931949c1d5f009385f30535bd566c8611e26 Mon Sep 17 00:00:00 2001 From: Adithya Reddy Nallabolu Date: Wed, 4 Nov 2026 12:54:20 -0500 Subject: [PATCH 056/128] Support affine int8 weight specifications in Arm quantization (#24337) Summary: Allow static per-tensor and per-channel affine weight specifications. Retain the channel axis when deriving bias quantization parameters. Serialize identical per-channel convolution weight zero points as the scalar required by TOSA and reject conflicting zero points explicitly. Reviewed By: rascani cc @digantdesai @freddan80 @per @zingo @oscarandersson8218 @mansnils @Sebastian-Larsson @robell @rascani Differential Revision: D128346146 Pull Request resolved: https://github.com/pytorch/executorch/pull/24619 --- backends/arm/operators/ops_quant_utils.py | 12 +- .../arm/quantizer/quantization_annotator.py | 3 +- backends/arm/quantizer/quantization_config.py | 15 +- .../arm/test/quantizer/affine_weights/BUCK | 25 +++ .../affine_weights/test_affine_weights.py | 169 ++++++++++++++++++ 5 files changed, 216 insertions(+), 8 deletions(-) create mode 100644 backends/arm/test/quantizer/affine_weights/BUCK create mode 100644 backends/arm/test/quantizer/affine_weights/test_affine_weights.py diff --git a/backends/arm/operators/ops_quant_utils.py b/backends/arm/operators/ops_quant_utils.py index fef9e3c56c1..25e35f92b20 100644 --- a/backends/arm/operators/ops_quant_utils.py +++ b/backends/arm/operators/ops_quant_utils.py @@ -20,7 +20,15 @@ def add_input_weight_zp_consts(tosa_graph, node, inputs, output_name): weight_zp = 0 if inputs[1].dtype == ts.DType.INT8: input_qparams = get_input_qparams(node) - weight_zp = input_qparams[1].zp # type: ignore[assignment] + zero_points = input_qparams[1].zp + if isinstance(zero_points, list): + if not zero_points or any(zp != zero_points[0] for zp in zero_points): + raise ValueError( + "TOSA convolution requires one common weight zero point" + ) + weight_zp = zero_points[0] + else: + weight_zp = zero_points input_zp_name = f"{output_name}_input_zp" weight_zp_name = f"{output_name}_weight_zp" @@ -29,7 +37,7 @@ def add_input_weight_zp_consts(tosa_graph, node, inputs, output_name): [1], inputs[0].dtype, [input_zp], name=input_zp_name ) weight_zp_tensor = tosa_graph.addConst( - [1], inputs[1].dtype, weight_zp, name=weight_zp_name + [1], inputs[1].dtype, [weight_zp], name=weight_zp_name ) return input_zp_tensor.name, weight_zp_tensor.name diff --git a/backends/arm/quantizer/quantization_annotator.py b/backends/arm/quantizer/quantization_annotator.py index 70f87267c65..020101a2718 100644 --- a/backends/arm/quantizer/quantization_annotator.py +++ b/backends/arm/quantizer/quantization_annotator.py @@ -129,7 +129,8 @@ def _adjust_weight_qspec_for_conv_transpose( if ( node.target != torch.ops.aten.conv_transpose2d.input or weight_qspec is None - or weight_qspec.qscheme != torch.per_channel_symmetric + or weight_qspec.qscheme + not in (torch.per_channel_symmetric, torch.per_channel_affine) ): return weight_qspec diff --git a/backends/arm/quantizer/quantization_config.py b/backends/arm/quantizer/quantization_config.py index 3cfc49c1dbe..297da259cf3 100644 --- a/backends/arm/quantizer/quantization_config.py +++ b/backends/arm/quantizer/quantization_config.py @@ -232,14 +232,13 @@ def get_weight_qspec( ) -> Optional[QuantizationSpecBase]: """Get the validated weight spec. - Validate that the weight qscheme is supported (per-tensor or - per-channel symmetric) before returning the spec. + Validate that the weight qscheme is supported before returning the spec. Returns: Optional[QuantizationSpecBase]: Weight spec, or ``None`` when unset. Raises: - ValueError: If the qscheme is not a supported symmetric scheme. + ValueError: If the qscheme is not a supported static scheme. """ if self.weight is None: @@ -248,6 +247,8 @@ def get_weight_qspec( if not hasattr(self.weight, "qscheme") or self.weight.qscheme not in [ torch.per_tensor_symmetric, torch.per_channel_symmetric, + torch.per_tensor_affine, + torch.per_channel_affine, ]: raise ValueError(f"Unsupported quantization_spec {self.weight} for weight") return self.weight @@ -354,7 +355,10 @@ def _derive_qparams_fn( ) ch_axis = None if self.weight is not None: - if qscheme == torch.per_channel_symmetric: + if qscheme in ( + torch.per_channel_symmetric, + torch.per_channel_affine, + ): ch_axis = self.weight.ch_axis if ( node.target == torch.ops.aten.conv_transpose2d.input @@ -466,7 +470,8 @@ def get_weight_qspec( node is not None and weight_qspec is not None and isinstance(weight_qspec, QuantizationSpec) - and weight_qspec.qscheme == torch.per_channel_symmetric + and weight_qspec.qscheme + in (torch.per_channel_symmetric, torch.per_channel_affine) and node.target == torch.ops.aten.conv_transpose2d.input ): # MLETORCH-1853: Fix lazy import when moving files around diff --git a/backends/arm/test/quantizer/affine_weights/BUCK b/backends/arm/test/quantizer/affine_weights/BUCK new file mode 100644 index 00000000000..54c78b6527e --- /dev/null +++ b/backends/arm/test/quantizer/affine_weights/BUCK @@ -0,0 +1,25 @@ +load("@fbcode_macros//build_defs:build_file_migration.bzl", "fbcode_target") +load("@fbcode_macros//build_defs:python_pytest.bzl", "python_pytest") +load("@fbsource//xplat/executorch/build:runtime_wrapper.bzl", "runtime") + +oncall("executorch") + +fbcode_target( + _kind = python_pytest, + name = "test_affine_weights", + srcs = ["test_affine_weights.py"], + env = {"TEST_RUNTIME_IS_NOT_OSS": "1" if not runtime.is_oss else "0"}, + preload_deps = ["//executorch/kernels/quantized:custom_ops_generated_lib"], + deps = [ + "//caffe2:torch", + "//executorch/backends/arm/_passes:passes", + "//executorch/backends/arm/operators:ops", + "//executorch/backends/arm/quantizer:lib", + "//executorch/backends/arm/test:arm_tester" if runtime.is_oss else "//executorch/backends/arm/test/tester/fb:arm_tester_fb", + "//executorch/backends/test/harness:tester", + "//pytorch/ao:torchao", + "fbsource//third-party/pypi/pytest:pytest", + "fbsource//third-party/tosa_tools:serializer", + "fbsource//third-party/tosa_tools:tosa_reference_model", + ], +) diff --git a/backends/arm/test/quantizer/affine_weights/test_affine_weights.py b/backends/arm/test/quantizer/affine_weights/test_affine_weights.py new file mode 100644 index 00000000000..4a004a8fcbd --- /dev/null +++ b/backends/arm/test/quantizer/affine_weights/test_affine_weights.py @@ -0,0 +1,169 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +from dataclasses import replace +from unittest.mock import Mock + +import pytest +import torch +import tosa_serializer as ts +from executorch.backends.arm._passes.quant_args import QuantArgs +from executorch.backends.arm.operators.ops_quant_utils import add_input_weight_zp_consts +from executorch.backends.arm.quantizer.arm_quantizer import ( + get_symmetric_quantization_config, +) +from executorch.backends.arm.quantizer.quantization_config import QuantizationConfig +from executorch.backends.arm.test.tester.test_pipeline import TosaPipelineINT +from executorch.backends.test.harness.stages import StageType +from torchao.quantization.pt2e.quantizer import ( + DerivedQuantizationSpec, + QuantizationSpec, +) + + +def _affine_config(per_channel: bool) -> QuantizationConfig: + config = get_symmetric_quantization_config(is_per_channel=per_channel) + weight = config.weight + assert isinstance(weight, QuantizationSpec) + return replace( + config, + weight=replace( + weight, + qscheme=( + torch.per_channel_affine if per_channel else torch.per_tensor_affine + ), + quant_min=-128, + ), + ) + + +@pytest.mark.parametrize("per_channel", [False, True]) +def test_affine_weight_specs_and_derived_channel_bias(per_channel: bool) -> None: + config = _affine_config(per_channel) + assert config.get_weight_qspec() is config.weight + graph = torch.fx.Graph() + node = graph.call_function( + torch.ops.aten.linear.default, + ( + graph.placeholder("input"), + graph.placeholder("weight"), + graph.placeholder("bias"), + ), + ) + bias = config.get_bias_qspec(node) + assert isinstance(bias, DerivedQuantizationSpec) + assert bias.ch_axis == (0 if per_channel else None) + + +def test_equal_channel_weight_zero_points_serialize_as_one_scalar() -> None: + graph = torch.fx.Graph() + node = graph.placeholder("conv") + node.meta["input_qparams"] = { + 0: QuantArgs(0.1, 0, -127, 127, torch.int8), + 1: QuantArgs([0.01, 0.02], [-28, -28], -128, 127, torch.int8, per_channel=True), + } + inputs = [Mock(dtype=ts.DType.INT8), Mock(dtype=ts.DType.INT8)] + serializer = Mock() + input_const, weight_const = Mock(), Mock() + input_const.name, weight_const.name = "input_zp", "weight_zp" + serializer.addConst.side_effect = [input_const, weight_const] + add_input_weight_zp_consts(serializer, node, inputs, "conv") + assert serializer.addConst.call_args_list[1].args == ([1], ts.DType.INT8, [-28]) + node.meta["input_qparams"][1] = QuantArgs( + [0.01, 0.02], [-28, -27], -128, 127, torch.int8, per_channel=True + ) + with pytest.raises(ValueError, match="common weight zero point"): + add_input_weight_zp_consts(serializer, node, inputs, "conv") + + +def _affine_model( + operator: str, conflicting_zero_points: bool = False +) -> tuple[torch.nn.Module, tuple[torch.Tensor]]: + model: torch.nn.Linear | torch.nn.Conv2d | torch.nn.ConvTranspose2d + if operator == "linear": + model = torch.nn.Linear(8, 3) + inputs = (torch.linspace(-1, 1, 8).reshape(1, 8),) + else: + model = ( + torch.nn.ConvTranspose2d(2, 3, 2) + if operator == "conv_transpose2d" + else torch.nn.Conv2d(2, 3, 2) + ) + inputs = (torch.linspace(-1, 1, 72).reshape(1, 2, 6, 6),) + axis = 1 if operator == "conv_transpose2d" else 0 + channel_shape = model.weight.movedim(axis, 0).shape + base = torch.linspace(-0.25, 1, model.weight.numel() // 3).reshape( + channel_shape[1:] + ) + weights = torch.stack([base * scale for scale in (1, 2, 3)]) + if conflicting_zero_points: + weights[0] += 0.25 + with torch.no_grad(): + model.weight.copy_(weights.movedim(0, axis)) + assert model.bias is not None + model.bias.copy_(torch.tensor([0.1, 0.2, 0.3])) + return model.eval(), inputs + + +def _check_affine_qparams( + pipeline: TosaPipelineINT[tuple[torch.Tensor]], per_channel: bool +) -> None: + graph_module = pipeline.tester.get_artifact(StageType.QUANTIZE) + op = ( + torch.ops.quantized_decomposed.dequantize_per_channel.default + if per_channel + else torch.ops.quantized_decomposed.dequantize_per_tensor.default + ) + weights = [ + node + for node in graph_module.graph.nodes + if node.target == op and node.args[-1] == torch.int8 + ] + if per_channel: + assert len(weights) == 1 + scales = graph_module.get_buffer(str(weights[0].args[1].target)) + zero_points = graph_module.get_buffer(str(weights[0].args[2].target)) + assert scales.numel() == 3 + assert torch.unique(scales).numel() == 3 + assert torch.unique(zero_points).numel() == 1 + assert zero_points[0] != 0 + biases = [ + node + for node in graph_module.graph.nodes + if node.target == op and node.args[-1] == torch.int32 + ] + assert len(biases) == 1 + assert biases[0].args[3] == 0 + assert graph_module.get_buffer(str(biases[0].args[1].target)).numel() == 3 + else: + weight = next(node for node in weights if node.args[0].op == "get_attr") + assert weight.args[2] != 0 + + +@pytest.mark.parametrize("operator", ["linear", "conv2d", "conv_transpose2d"]) +@pytest.mark.parametrize("per_channel", [False, True]) +def test_affine_weights_tosa_pipeline(operator: str, per_channel: bool) -> None: + model, inputs = _affine_model(operator) + pipeline = TosaPipelineINT[tuple[torch.Tensor]]( + model, + inputs, + aten_op=f"torch.ops.aten.{operator}.{'input' if operator == 'conv_transpose2d' else 'default'}", + per_channel_quantization=per_channel, + ) + pipeline.quantizer.set_global(_affine_config(per_channel)) + pipeline.add_stage_after("quantize", _check_affine_qparams, pipeline, per_channel) + pipeline.run() + assert pipeline.has_stage("run_method_and_compare_outputs") + + +def test_conflicting_affine_weight_zero_points_rejected_by_pipeline() -> None: + model, inputs = _affine_model("conv2d", conflicting_zero_points=True) + pipeline = TosaPipelineINT[tuple[torch.Tensor]]( + model, inputs, aten_op="torch.ops.aten.conv2d.default" + ) + pipeline.quantizer.set_global(_affine_config(True)) + with pytest.raises(ValueError, match="common weight zero point"): + pipeline.run() From 3a784f823b061b2b8883888d2a39fcf88f36d584 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Wed, 4 Nov 2026 10:35:44 -0800 Subject: [PATCH 057/128] Refuse ops coremltools cannot handle in the Core ML partitioner, and say why (#24425) ## What is wrong today The Core ML partitioner hands Core ML some ops that coremltools cannot handle, so lowering fails inside coremltools, Core ML fails to load the model, or the result is wrong: - `min`, `max`, `amin`, `amax`, `minimum` and `maximum` raise a `ValueError` when every input is bool, as in the all-true check that `torch.distributions.MultivariateNormal` exports. - A reduction of a 0-d tensor, such as `x.sum().sum()`, fails to load: Core ML's reduce ops need rank 1 or more. - `index_put` next to a slice, such as `y[:, idx] = v`, converts only with single-entry int indices, `accumulate=False`, static sizes in the other dims, and a non-bool `y` below iOS 18. With a 0-d index or several indices, values can land in the wrong place. Without a slice, several index tensors must have the same shape and no bool mask, and a bool `y` comes back as int32. ## What this change does - The partitioner refuses these forms. They run on the CPU, and the rest of the graph goes to Core ML. Forms coremltools handles, such as the single-entry index of a KV cache update, stay in Core ML. - Some reductions of a 0-d tensor stay in Core ML and still fail as on main. A `std` or vector norm has no CPU kernel, and refusing it can leave a call with no kernel. A sum over a dim would make ExecuTorch's optimized sum kernel abort. - With `lower_full_graph=True`, a refused form now raises with its reason instead of calling the whole op unsupported, also for forms that used to lower, such as "aten.index_put.default on a bool tensor without a slice is not supported by CoreML". - The int4 recipe tests, flaky on main too, now fix the seed and use thresholds that float16 compute and int4 rounding can meet. ## Testing - `test_coreml_partitioner.py`, `test_torch_ops.py` and `test_coreml_recipes.py` pass on Linux and macOS. - The new tests check which ops stay out of Core ML, also with a dynamic batch size for `index_put`, check the error reasons, and on macOS compare outputs with eager PyTorch. The iOS 18 case needs macOS 15. - Each new test fails when its check is removed. --- .../coreml/partition/coreml_partitioner.py | 233 ++++++++++++-- .../coreml/test/test_coreml_partitioner.py | 284 ++++++++++++++++++ .../apple/coreml/test/test_coreml_recipes.py | 10 +- 3 files changed, 499 insertions(+), 28 deletions(-) diff --git a/backends/apple/coreml/partition/coreml_partitioner.py b/backends/apple/coreml/partition/coreml_partitioner.py index 4453818f956..895f47e6443 100644 --- a/backends/apple/coreml/partition/coreml_partitioner.py +++ b/backends/apple/coreml/partition/coreml_partitioner.py @@ -26,6 +26,7 @@ from executorch.exir.backend.utils import tag_constant_data, tag_mutated_buffer from executorch.exir.dialects._ops import ops as exir_ops from torch.export.exported_program import ExportedProgram +from torch.fx.experimental.symbolic_shapes import statically_known_true, sym_eq from torch.fx.passes.infra.partitioner import CapabilityBasedPartitioner from torch.fx.passes.operator_support import OperatorSupportBase @@ -154,6 +155,56 @@ def _reads_or_produces_bfloat16(node: torch.fx.Node) -> bool: return any(isinstance(v, torch.Tensor) and v.dtype == torch.bfloat16 for v in vals) +# No upstream issue yet. For example, Core ML fails to load x.sum().sum(): it gets +# the second sum as a reduce_sum of a 0-d tensor, "Param 'x' has incorrect type +# for operator 'reduce_sum'". A std or vector norm of a 0-d tensor fails too, but +# stays: it has no CPU kernel, and a refused call can decompose into an op that +# another call keeps whole. +_REDUCTION_TARGETS = frozenset( + target + for aten in (torch.ops.aten, exir_ops.edge.aten) + for target in ( + aten.sum.default, + aten.sum.dim_IntList, + aten.mean.default, + aten.mean.dim, + aten.amax.default, + aten.amin.default, + aten.max.default, + aten.max.dim, + aten.min.default, + aten.min.dim, + aten.argmax.default, + aten.argmin.default, + aten.any.default, + aten.any.dim, + aten.any.dims, + aten.all.default, + aten.all.dim, + aten.all.dims, + aten.var.default, + aten.var.dim, + aten.var.correction, + ) +) +_SUM_OVER_DIMS = (torch.ops.aten.sum.dim_IntList, exir_ops.edge.aten.sum.dim_IntList) + + +def _reduces_0d_tensor(node: torch.fx.Node) -> bool: + """Core ML cannot run the reductions above on a 0-d tensor: its reduce ops + need rank 1 or more. + + A sum over a given dim, such as x.sum().sum(0), stays and still fails to + lower, since ExecuTorch's optimized sum kernel aborts on it at run time. + """ + if node.target not in _REDUCTION_TARGETS or ( + node.target in _SUM_OVER_DIMS and len(node.args) > 1 and node.args[1] + ): + return False + val = node.args[0].meta.get("val") + return isinstance(val, torch.Tensor) and val.dim() == 0 + + def _is_view_op(op: torch._ops.OpOverload) -> bool: schema = op._schema if len(schema.arguments) == 0: @@ -162,18 +213,139 @@ def _is_view_op(op: torch._ops.OpOverload) -> bool: return (alias_info is not None) and (not alias_info.is_write) +# The partitioner picks the ops to keep whole from torch ops. Refusing min.other +# and max.other there lets lowering turn them into minimum and maximum. The +# others stay whole, as core ATen ops or ops the partitioner keeps, so only their +# edge ops reach it. +_MIN_MAX_TARGETS = ( + torch.ops.aten.min.other, + torch.ops.aten.max.other, + exir_ops.edge.aten.min.default, + exir_ops.edge.aten.min.dim, + exir_ops.edge.aten.max.default, + exir_ops.edge.aten.max.dim, + exir_ops.edge.aten.amin.default, + exir_ops.edge.aten.amax.default, + exir_ops.edge.aten.minimum.default, + exir_ops.edge.aten.maximum.default, +) + + +# No upstream issue yet. For example, converting x.amax() of a bool x raises +# 'Op "amax" (op_type: reduce_max) Input x="x" expects tensor or scalar of +# dtype from type domain ['fp16', 'fp32', 'int32'] but got tensor[3,2,bool]'. +def _is_min_max_of_bool(node: torch.fx.Node) -> bool: + """coremltools 9.0 cannot compute a min or max of bool tensors. + + It raises ``ValueError`` when every input is bool, so that the result is + bool, such as for the all-true check that torch.distributions exports, + instead of reporting the op as unsupported. A bool next to a float or int + tensor works, and so do int tensors. + """ + if node.target not in _MIN_MAX_TARGETS: + return False + val = node.meta.get("val") + if isinstance(val, (list, tuple)): + val = val[0] + return isinstance(val, torch.Tensor) and val.dtype == torch.bool + + +_INDEX_PUT_TARGETS = ( + torch.ops.aten.index_put.default, + exir_ops.edge.aten.index_put.default, +) + + +# https://github.com/apple/coremltools/issues/2141 covers a bool index, which +# coremltools 9.0 converts only on its own. No upstream issue covers the other +# forms yet. For example, y[:, idx] = v with idx = torch.tensor([0, 2]) raises +# "Input squeeze_0 has rank 1 != other inputs rank 0". +def _index_put_refusal( + node: torch.fx.Node, minimum_deployment_target: Optional[ct.target] +) -> Optional[str]: + """The form of an ``index_put`` that coremltools 9.0 cannot convert, such + as "with accumulate=True next to a slice", or None for any other node. + + With a whole slice, such as ``y[:, idx] = v``, it writes ``v`` into the + part of ``y`` the indices select, keeping each indexed dim as size 1. That + needs ``accumulate=False``, int indices of shape ``()`` or ``(1,)``, a + static size in every other dim, and a non-bool ``y`` below iOS 18. For a + size known only at run time, it raises "Cannot add const". With a 0-d + index or several indices, torch lays out that part differently, so ``v`` + may only vary in the dims after the last index. Without a whole slice, + several indices need the same shape and no bool mask, and a bool ``y`` + comes back as int32. It raises or writes the wrong place for any other + form, such as ``idx`` with two entries, instead of reporting the op as + unsupported. + """ + if node.target not in _INDEX_PUT_TARGETS: + return None + y, values = node.args[0].meta.get("val"), node.args[2].meta.get("val") + indices = [i if i is None else i.meta.get("val") for i in node.args[1]] + given = {dim: indices[dim] for dim, i in enumerate(node.args[1]) if i is not None} + # Export always sets val, so an index_put without it is left to coremltools. + if not given or not all( + isinstance(t, torch.Tensor) for t in (y, values, *given.values()) + ): + return None + if len(given) == len(indices): + if y.dtype == torch.bool: + return "on a bool tensor without a slice" + if len(indices) > 1 and any( + index.dtype == torch.bool + or not statically_known_true(sym_eq(index.shape, indices[0].shape)) + for index in indices + ): + return "with indices that differ in shape or include a bool mask" + return None + accumulate = ( + node.args[3] if len(node.args) > 3 else node.kwargs.get("accumulate", False) + ) + if accumulate: + return "with accumulate=True next to a slice" + if any( + index.dtype == torch.bool + or index.dim() > 1 + or not statically_known_true(index.numel() == 1) + for index in given.values() + ): + return ( + "with an index other than an int tensor of shape () or (1,) next to a slice" + ) + selected = [1 if dim in given else size for dim, size in enumerate(y.shape)] + if any(isinstance(size, torch.SymInt) for size in selected): + return "with a size known only at run time next to a slice" + reorders = len(given) > 1 or any(index.dim() == 0 for index in given.values()) + after = len(selected) - 1 - max(given) + if reorders and not all( + statically_known_true(v == 1) + for v in values.shape[: max(0, values.dim() - after)] + ): + return ( + "with a 0-d index or several indices, and values that vary before " + "the last index" + ) + if y.dtype == torch.bool and ( + minimum_deployment_target is None or minimum_deployment_target < ct.target.iOS18 + ): + return "on a bool tensor below minimum_deployment_target=ct.target.iOS18" + return None + + class _OperatorsSupportedForCoreMLBackend(OperatorSupportBase): def __init__( self, skip_ops_for_coreml_delegation: Optional[List[str]] = None, lower_full_graph: bool = False, log: bool = False, + minimum_deployment_target: Optional[ct.target] = None, ) -> None: if skip_ops_for_coreml_delegation is None: skip_ops_for_coreml_delegation = [] super().__init__() self.skip_ops_for_coreml_delegation = skip_ops_for_coreml_delegation self.lower_full_graph = lower_full_graph + self.minimum_deployment_target = minimum_deployment_target self._logged_msgs = set() self._log = log @@ -195,7 +367,9 @@ def should_skip_op_for_delegation(self, node_target_name: str) -> bool: return True return False - def should_override_support(self, node) -> bool: + def override_reason(self, node) -> Optional[str]: + """Why CoreML cannot take node, even when coremltools converts its op.""" + name = getattr(node.target, "__name__", node.target) # https://github.com/apple/coremltools/issues/2573 if ( node.target @@ -208,20 +382,14 @@ def should_override_support(self, node) -> bool: and "alpha" in node.kwargs and node.kwargs["alpha"] != 1 ): - self.log_once( - "torch.ops.aten.{sub, add}.Tensor with alpha != 1 is not supported by CoreML. Overriding support." - ) - return True + return "torch.ops.aten.{sub, add}.Tensor with alpha != 1 is not supported by CoreML." # Ops that are unsupported by CoreML purely on the basis of their # target — no per-arg conditions to check. Grouped by upstream issue # so the comment trail still points at the underlying coremltools / # executorch bug for each entry. if node.target in _UNSUPPORTED_OP_TARGETS: - self.log_once( - f"{node.target} is not supported by CoreML. Overriding op support." - ) - return True + return f"{name} is not supported by CoreML." if _touches_unsupported_high_rank_tensor( node, @@ -229,25 +397,26 @@ def should_override_support(self, node) -> bool: getattr(n.target, "__name__", "").lower() ), ): - self.log_once( - f"{node.target} on a tensor above rank 5 is not supported by " - "CoreML. Overriding op support." - ) - return True + return f"{name} on a tensor above rank 5 is not supported by CoreML." if _is_arg_min_max_over_flattened_input(node): - self.log_once( + return ( "torch.ops.aten.{argmax, argmin}.default with dim=None is " - "not supported by CoreML. Overriding op support." + "not supported by CoreML." ) - return True + + if _reduces_0d_tensor(node): + return f"{name} of a 0-d tensor is not supported by CoreML." if _reads_or_produces_bfloat16(node): - self.log_once( - f"{node.target} on a bfloat16 tensor is not supported by CoreML. " - "Overriding op support." - ) - return True + return f"{name} on a bfloat16 tensor is not supported by CoreML." + + if _is_min_max_of_bool(node): + return f"{name} on bool tensors is not supported by CoreML." + + refusal = _index_put_refusal(node, self.minimum_deployment_target) + if refusal is not None: + return f"{name} {refusal} is not supported by CoreML." # TODO: enable this after bugs in ExecuTorch's partitioner are fixed # # If lower_full_graph=False, do not partition nodes with symbolic args because it can result in symbolic args @@ -266,7 +435,7 @@ def should_override_support(self, node) -> bool: # ) # return True - return False + return None def is_node_supported(self, submodules, node: torch.fx.Node) -> bool: # get_attr node can always be supported on any backend @@ -284,13 +453,20 @@ def is_node_supported(self, submodules, node: torch.fx.Node) -> bool: is_supported = ct.converters.mil.frontend.torch.is_torch_fx_node_supported( node ) - if self.should_override_support(node): + reason = self.override_reason(node) if is_supported else None + if reason is not None: + self.log_once(f"{reason} Overriding op support.") is_supported = False if not is_supported: if self.lower_full_graph: + unsupported = ( + f"CoreML does not support the op {node_target_name}" + if reason is None + else reason.rstrip(".") + ) raise NotImplementedError( - f"""CoreML does not support the op {node_target_name}, but you have set lower_full_graph=True in the CoreMLPartitioner. + f"""{unsupported}, but you have set lower_full_graph=True in the CoreMLPartitioner. Please set lower_full_graph=False in the CoreMLPartitioner to allow running unsupported ops outside of CoreML. Note that setting lower_full_graph=False may affect performance of CoreML and the available features. As an alternative to setting lower_full_graph=False, you can try rewriting your model to avoid using this op. @@ -343,6 +519,11 @@ def __init__( self.take_over_mutable_buffer = take_over_mutable_buffer self.lower_full_graph = lower_full_graph self.take_over_constant_data = take_over_constant_data + self._minimum_deployment_target = ( + CoreMLBackend.min_deployment_target_from_compile_specs( + self.delegation_spec.compile_specs + ) + ) self._logged_msgs = set() if self.lower_full_graph: @@ -399,6 +580,7 @@ def partition(self, exported_program: ExportedProgram) -> PartitionResult: self.skip_ops_for_coreml_delegation, self.lower_full_graph, log=True, + minimum_deployment_target=self._minimum_deployment_target, ), allows_single_node_partition=True, ) @@ -438,6 +620,7 @@ def ops_to_not_decompose( self.skip_ops_for_coreml_delegation, self.lower_full_graph, log=False, + minimum_deployment_target=self._minimum_deployment_target, ) # CoreML prevents certain ops (like triu) from lowering to CoreML when put in the ExecuTorch op namespace diff --git a/backends/apple/coreml/test/test_coreml_partitioner.py b/backends/apple/coreml/test/test_coreml_partitioner.py index 0129eb1d33f..6066299bca3 100644 --- a/backends/apple/coreml/test/test_coreml_partitioner.py +++ b/backends/apple/coreml/test/test_coreml_partitioner.py @@ -17,6 +17,9 @@ from executorch.backends.apple.coreml.compiler import CoreMLBackend from executorch.backends.apple.coreml.partition import CoreMLPartitioner +from executorch.backends.apple.coreml.test.test_coreml_utils import ( + IS_VALID_TEST_RUNTIME, +) from executorch.exir.backend.utils import format_delegated_graph @@ -521,6 +524,287 @@ def forward(self, q): out = program.load_method("forward").execute((x,))[0] self.assertTrue(torch.allclose(out, model(x), atol=5e-2, rtol=5e-2)) + def test_min_max_of_bool_is_skipped(self): + """ + coremltools raises on every form of a min or max of bool tensors, such + as the all-true check torch.distributions exports. The partitioner + must leave those ops to the portable backend and still delegate the + rest, including a min or max with a float input. + """ + + class Model(torch.nn.Module): + def forward(self, x): + ok = (x.tril() == x).view(1, -1) + no = ok.flip(-1) + reduced = [ok.min(), ok.max(), ok.min(-1)[0], ok.max(-1)[0]] + reduced += [ok.amin(-1), ok.amax(-1)] + paired = [torch.min(ok, no), torch.max(ok, no)] + paired += [torch.minimum(ok, no), torch.maximum(ok, no)] + flags = sum(r.to(x.dtype).sum() for r in reduced) + both = sum(p.to(x.dtype) for p in paired).view(x.shape) + floats = x.amax(-1, keepdim=True) + torch.maximum(x > 0.5, x) + return x * 2 + flags + both + floats + + model = Model().eval() + # Core ML computes in float16, so use values it holds exactly, or + # x > 0.5 can differ from eager for x just above 0.5. + x = torch.rand(14, 14).half().float() + ep = torch.export.export(model, (x,), strict=True) + edge = executorch.exir.to_edge_transform_and_lower( + ep, partitioner=[CoreMLPartitioner()] + ) + op_names = [ + n.target.__name__ + for n in edge.exported_program().graph.nodes + if n.op == "call_function" + ] + self.assertIn("executorch_call_delegate", op_names) + for name in ("min.default", "max.default", "min.dim", "max.dim"): + self.assertIn(f"aten.{name}", op_names) + self.assertIn("aten.amin.default", op_names) + # The float amax and maximum stay in Core ML, and lowering turns the + # min and max of two tensors into minimum and maximum. + self.assertEqual(op_names.count("aten.amax.default"), 1) + self.assertEqual(op_names.count("aten.minimum.default"), 2) + self.assertEqual(op_names.count("aten.maximum.default"), 2) + if _TEST_RUNTIME: + program = Runtime.get().load_program(edge.to_executorch().buffer) + out = program.load_method("forward").execute((x,))[0] + self.assertTrue(torch.allclose(out, model(x), atol=1e-2)) + + def test_index_put_forms_coremltools_gets_wrong(self): + """ + coremltools fails some forms of ``index_put``, such as + ``y[:, idx] = v`` with two entries in ``idx``, or writes them to the + wrong place, so the partitioner refuses those, and they run on the + portable backend. With lower_full_graph=True, lowering fails and says + why. + """ + + class Scatter(torch.nn.Module): + def __init__(self): + super().__init__() + self.register_buffer("cols", torch.tensor([0, 2])) + self.register_buffer("col", torch.tensor([1])) + self.register_buffer("last", torch.tensor(3)) + self.register_buffer("rows", torch.tensor([[1], [2]])) + self.register_buffer("flag", torch.tensor([2])) + self.register_buffer("rows_mask", torch.tensor([True, True, True])) + self.register_buffer("trio", torch.tensor([0, 2, 3])) + self.register_buffer("grid", torch.tensor([[1]])) + self.register_buffer("yes", torch.tensor([True])) + self.register_buffer("pair", torch.tensor([0, 1])) + + def forward(self, x, a, b, c): + y = x * 2 + y[:, self.cols] = a + y[:, self.grid] = a[:, :, None] + y[self.rows, self.cols] = b + # Same-shape indices without a slice stay in Core ML, also when + # a refused write reads the same index. + y[self.cols, self.cols] = a[:2, 0] + y[self.rows_mask, self.trio] = c + torch.ops.aten.index_put_(y, [None, self.col], a, True) + torch.ops.aten.index_put_(y, [None, self.last], c) + mask = y > 0.5 + mask[:, self.flag] = a > 0.5 + # Core ML would return this bool write as int32, and ~ would + # then read it wrong. + flags = x > 0.5 + flags[self.pair, self.pair] = a[:2, 0] > 0.5 + z = a * 2 + z[:, self.yes] = c[:, None] + return y + mask + z + ~flags + + class Scatter3d(torch.nn.Module): + def __init__(self): + super().__init__() + self.register_buffer("last", torch.tensor(2)) + self.register_buffer("mid", torch.tensor(1)) + self.register_buffer("row", torch.tensor([1])) + self.register_buffer("col", torch.tensor([2])) + + def forward(self, x, v): + y = x.clone() + torch.ops.aten.index_put_(y, [None, None, self.last], v) + # Values that vary only after the index stay in Core ML. + torch.ops.aten.index_put_(y, [None, self.mid], v[0] * torch.arange(3.0)) + torch.ops.aten.index_put_(y, [None, self.row, self.col], v) + return y * 2 + + ios18 = CoreMLBackend.generate_compile_specs( + minimum_deployment_target=ct.target.iOS18 + ) + # Values that float16 holds exactly, so that the comparisons with 0.5 + # in Core ML agree with eager. + scatter_inputs = tuple( + torch.rand(shape).half().float() for shape in ((3, 4), (3, 1), (2, 2), (3,)) + ) + scatter3d_inputs = (torch.rand(2, 2, 3), torch.rand(2, 1)) + cases = [ + (Scatter(), scatter_inputs, 9, None), + # From iOS 18 the bool write next to a slice stays in Core ML. + (Scatter(), scatter_inputs, 8, ios18), + (Scatter3d(), scatter3d_inputs, 2, None), + ] + for model, inputs, cpu_calls, compile_specs in cases: + with self.subTest( + model=type(model).__name__, ios18=compile_specs is not None + ): + ep = torch.export.export(model.eval(), inputs, strict=True) + edge = executorch.exir.to_edge_transform_and_lower( + ep, partitioner=[CoreMLPartitioner(compile_specs=compile_specs)] + ) + op_names = [ + n.target.__name__ + for n in edge.exported_program().graph.nodes + if n.op == "call_function" + ] + self.assertIn("executorch_call_delegate", op_names) + self.assertEqual(op_names.count("aten.index_put.default"), cpu_calls) + # An iOS 18 program needs macOS 15 to run. + if _TEST_RUNTIME and (compile_specs is None or IS_VALID_TEST_RUNTIME): + program = Runtime.get().load_program(edge.to_executorch().buffer) + out = program.load_method("forward").execute(inputs)[0] + self.assertTrue(torch.allclose(out, model(*inputs), atol=1e-2)) + + class BoolWrite(torch.nn.Module): + def __init__(self, index, sliced=True): + super().__init__() + self.register_buffer("index", index) + self.sliced = sliced + + def forward(self, x, a): + indices = [None, self.index] if self.sliced else [self.index] + return torch.ops.aten.index_put(x > 0.5, indices, a > 0.5) + + for model, inputs, reason in ( + (Scatter3d(), scatter3d_inputs, "values that vary before the last index"), + ( + BoolWrite(torch.tensor([2])), + scatter_inputs[:2], + "below minimum_deployment_target=ct.target.iOS18", + ), + # A bool write that a later target would not fix gives its own reason. + (BoolWrite(torch.tensor([0, 2])), scatter_inputs[:2], "other than an int"), + ( + BoolWrite(torch.tensor([0, 2]), sliced=False), + (scatter_inputs[0], scatter_inputs[1][:2]), + "on a bool tensor without a slice", + ), + ): + with self.assertRaisesRegex(NotImplementedError, reason) as error: + executorch.exir.to_edge_transform_and_lower( + torch.export.export(model, inputs, strict=True), + partitioner=[CoreMLPartitioner(lower_full_graph=True)], + ) + self.assertNotIn("does not support the op", str(error.exception)) + # With a batch size known only at run time, the write that stays in + # Core ML above goes to the CPU too. + batch = torch.export.Dim("batch", min=2, max=8) + ep = torch.export.export( + Scatter3d(), + scatter3d_inputs, + dynamic_shapes=({0: batch}, {0: batch}), + strict=True, + ) + edge = executorch.exir.to_edge_transform_and_lower( + ep, partitioner=[CoreMLPartitioner()] + ) + op_names = [ + n.target.__name__ + for n in edge.exported_program().graph.nodes + if n.op == "call_function" + ] + self.assertEqual(op_names.count("aten.index_put.default"), 3) + + def test_reduction_of_0d_tensor_is_skipped(self): + """ + Core ML cannot reduce a 0-d tensor. The partitioner must leave such + reductions to the CPU and still delegate the rest. + """ + + class Model(torch.nn.Module): + def forward(self, x): + total = x.sum() + flag = x[0, 0] > 0.5 + zero_d = [total.sum(), total.mean(), total.mean(0), total.amax()] + zero_d += [total.amin(), total.max(), total.max(0)[0], total.min()] + zero_d += [total.min(0)[0], total.argmax(0), total.argmin(0)] + zero_d += [x[0, 0].sum(), flag.any(), flag.any(0), flag.any((0,))] + zero_d += [flag.all(), flag.all(0), flag.all((0,))] + zero_d += [total.var(correction=0), total.var(unbiased=False)] + zero_d += [total.var(0, False)] + return x * 2 + sum(r.to(x.dtype) for r in zero_d) + + model = Model().eval() + # Core ML computes in float16, so use values it holds exactly, or + # x[0, 0] > 0.5 can differ from eager for x just above 0.5. + x = torch.rand(2, 3).half().float() + ep = torch.export.export(model, (x,), strict=True) + edge = executorch.exir.to_edge_transform_and_lower( + ep, partitioner=[CoreMLPartitioner()] + ) + op_names = [ + n.target.__name__ + for n in edge.exported_program().graph.nodes + if n.op == "call_function" + ] + self.assertIn("executorch_call_delegate", op_names) + # One per reduction, in the order of the model. torch makes all() a + # logical_not of any() of a logical_not, which Core ML takes, and every + # var a var.correction. + reductions = [ + "sum.dim_IntList", + "mean.default", + "mean.dim", + "amax.default", + "amin.default", + "max.default", + "max.dim", + "min.default", + "min.dim", + "argmax.default", + "argmin.default", + "sum.dim_IntList", + "any.default", + "any.dim", + "any.dims", + "any.dims", + "any.dim", + "any.dims", + ] + ["var.correction"] * 3 + self.assertEqual( + sorted( + name + for name in op_names + if name not in ("executorch_call_delegate", "getitem") + ), + sorted(f"aten.{name}" for name in reductions), + ) + if _TEST_RUNTIME: + program = Runtime.get().load_program(edge.to_executorch().buffer) + out = program.load_method("forward").execute((x,))[0] + self.assertTrue(torch.allclose(out, model(x), atol=1e-2, rtol=1e-2)) + + # A sum of a 0-d tensor over a dim stays in Core ML, where lowering + # fails as before, since ExecuTorch's optimized sum kernel aborts on it. + class SumOverDim(torch.nn.Module): + def forward(self, x): + return x * x.sum().sum(0) + + ep = torch.export.export(SumOverDim(), (x,), strict=True) + result = CoreMLPartitioner().partition( + executorch.exir.to_edge(ep).exported_program() + ) + sums = [ + n + for n in result.tagged_exported_program.graph.nodes + if getattr(n.target, "__name__", "") == "aten.sum.dim_IntList" + ] + self.assertEqual(len(sums), 2) + self.assertTrue(all("delegation_tag" in n.meta for n in sums)) + def test_tied_embedding_is_quantized_with_its_linear(self): """A weight that is both an embedding table and an output projection lowers. diff --git a/backends/apple/coreml/test/test_coreml_recipes.py b/backends/apple/coreml/test/test_coreml_recipes.py index 98d240d74b5..17b47bc3ae9 100644 --- a/backends/apple/coreml/test/test_coreml_recipes.py +++ b/backends/apple/coreml/test/test_coreml_recipes.py @@ -120,6 +120,7 @@ def test_fp_recipes_with_custom_parameters(self): def test_int4_weight_only_per_channel(self): """Test INT4 weight-only per-channel quantization""" + torch.manual_seed(0) model = TestHelperModules.TwoLinearModule().eval() example_inputs = [(torch.randn(9, 8),)] @@ -132,10 +133,13 @@ def test_int4_weight_only_per_channel(self): ) self.check_fully_delegated(session) self._compare_eager_quantized_model_outputs(session, example_inputs, atol=1e-02) - self._compare_eager_unquantized_model_outputs(session, model, example_inputs) + self._compare_eager_unquantized_model_outputs( + session, model, example_inputs, sqnr_threshold=15 + ) def test_int4_weight_only_per_group(self): """Test INT4 weight-only per-group quantization with different group sizes""" + torch.manual_seed(0) class CustomTwoLinearModel(nn.Module): def __init__(self): @@ -149,7 +153,7 @@ def forward(self, x): return x model = CustomTwoLinearModel().eval() - example_inputs = [(torch.randn(1, 32),)] + example_inputs = [(torch.randn(16, 32),)] # Test with different group sizes for group_size in [8, 16, 32]: with self.subTest(group_size=group_size): @@ -165,7 +169,7 @@ def forward(self, x): self.check_fully_delegated(session) self._compare_eager_quantized_model_outputs( - session, example_inputs, atol=1e-3 + session, example_inputs, atol=1e-2 ) self._compare_eager_unquantized_model_outputs( session, model, example_inputs, sqnr_threshold=15 From a80cc6d02435cbb59cd490cf442b508496ae3e4b Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Wed, 4 Nov 2026 10:39:30 -0800 Subject: [PATCH 058/128] Fix optimized sum.IntList_out 0-d crash and precision, and kernel test build with GCC on aarch64 (#24596) ### Summary This fixes a crash and a precision bug in the fast path of the optimized CPU kernel for `sum.IntList_out`, and makes the kernel tests compile with GCC on aarch64. #### Crash on a 0-d input with a dim A 0-d tensor holds a single value and has no dimensions. PyTorch allows summing it over dim 0 or -1. With the optimized kernels, a model that does any of these aborts: ```python x.sum().sum(0) x.sum().sum(-1) x.sum().sum(0, keepdim=True) ``` The fast path turns the dim into an index `d` and calls `in.size(d)`. A 0-d tensor has no sizes, so the range check aborts: ``` In function size(), assert failed (dim < dim_ && dim >= 0): Dimension out of range (expected to be in range of [0, -1], but got 0 ``` **Fix:** the fast path now requires `in.dim() > 0`. A 0-d input goes to the portable kernel, which handles it. #### Wrong results for integer and Double sums The fast path adds in fp32 (32-bit float) but accepted every real dtype. A Long tensor `{16777216, 1}` summed over dim 0 gave `16777216`, because 2^24 + 1 cannot be stored exactly in fp32. **Fix:** the fast path now only takes Float, Half and BFloat16, for which the portable kernel also adds in fp32. Other dtypes go to the portable kernel, which adds in the input dtype, and may be slower for them than the fp32 fast path was. Float, Half and BFloat16 behave as before. #### Kernel tests do not compile with GCC on aarch64 On aarch64, the only converting constructor of `c10::Half` takes `__fp16`, and GCC 13 rejects some braced Half values as narrowing: - `op_convolution_backward_test`: integer reference values that are not exact in Half. They are now held as float and converted explicitly. - `op_max_test`, `op_min_test`, `op_scalar_tensor_test`: the float `INFINITY`. They now use `std::numeric_limits<...>::infinity()` of the element type, like other kernel tests. No CI job builds the C++ kernel tests with GCC on aarch64, so CI did not catch this. ### Test plan New tests in `op_sum_test.cpp`: `ZeroDimInputWithDim` (0-d Float tensor, dim 0 and -1, with and without `keepdim`) and `LongAndDoubleSumsAreExact`. `op_sum_test` is now listed as an optimized kernel test in Buck and CMake. CMake builds of `portable_kernels_test`, `optimized_kernels_test` and `optimized_portable_kernels_test`: - Without the 0-d fix, `ZeroDimInputWithDim` aborts in `optimized_kernels_test`. Without the precision fix, `LongAndDoubleSumsAreExact` fails there. Both pass with the portable kernels. - On current main with GCC 13 on aarch64 and default flags, the test files listed above fail to compile. - With this change, all 3 binaries compile and their full suites pass. Tested on Linux aarch64. Only the 0-d fix was also tested on Linux x86_64. Buck targets were not run. --- kernels/optimized/cpu/op_sum.cpp | 39 +++++++++---------- kernels/test/CMakeLists.txt | 1 + kernels/test/op_convolution_backward_test.cpp | 18 ++++++--- kernels/test/op_max_test.cpp | 2 +- kernels/test/op_min_test.cpp | 2 +- kernels/test/op_scalar_tensor_test.cpp | 4 +- kernels/test/op_sum_test.cpp | 31 +++++++++++++++ kernels/test/targets.bzl | 2 +- 8 files changed, 70 insertions(+), 29 deletions(-) diff --git a/kernels/optimized/cpu/op_sum.cpp b/kernels/optimized/cpu/op_sum.cpp index 059153120ab..41539f15351 100644 --- a/kernels/optimized/cpu/op_sum.cpp +++ b/kernels/optimized/cpu/op_sum.cpp @@ -157,11 +157,14 @@ Tensor& opt_sum_dim_out( return out; } - // Fast path: single reduction dim, matching dtype, non-complex, contiguous. - // Anything else falls through to the portable kernel. - const bool fast_eligible = dim_list.has_value() && - dim_list.value().size() == 1 && in.scalar_type() == out.scalar_type() && - !executorch::runtime::isComplexType(in.scalar_type()) && + // Fast path: non-0-d input, single reduction dim, matching dtype, + // contiguous. It accumulates in fp32, so it only takes Float, Half and + // BFloat16. Anything else falls through to the portable kernel. + const ScalarType in_type = in.scalar_type(); + const bool fast_eligible = in.dim() > 0 && dim_list.has_value() && + dim_list.value().size() == 1 && in_type == out.scalar_type() && + (in_type == ScalarType::Float || in_type == ScalarType::Half || + in_type == ScalarType::BFloat16) && tensor_is_contiguous(in); if (fast_eligible) { @@ -177,21 +180,17 @@ Tensor& opt_sum_dim_out( // @lint-ignore CLANGTIDY facebook-hte-CArray static constexpr const char op_name[] = "sum.IntList_out"; - bool handled = false; - ET_SWITCH_REALHBBF16_TYPES(in.scalar_type(), ctx, op_name, CTYPE, [&] { - const CTYPE* ip = in.const_data_ptr(); - CTYPE* op = out.mutable_data_ptr(); - if (inner_size == 1) { - sum_innermost(ip, op, outer_size, reduce_size); - handled = true; - } else { - sum_strided(ip, op, outer_size, reduce_size, inner_size); - handled = true; - } - }); - if (handled) { - return out; - } + ET_SWITCH_THREE_TYPES( + Float, Half, BFloat16, in_type, ctx, op_name, CTYPE, [&] { + const CTYPE* ip = in.const_data_ptr(); + CTYPE* op = out.mutable_data_ptr(); + if (inner_size == 1) { + sum_innermost(ip, op, outer_size, reduce_size); + } else { + sum_strided(ip, op, outer_size, reduce_size, inner_size); + } + }); + return out; } // Fallback. diff --git a/kernels/test/CMakeLists.txt b/kernels/test/CMakeLists.txt index de96692da7c..c05b5578b7f 100644 --- a/kernels/test/CMakeLists.txt +++ b/kernels/test/CMakeLists.txt @@ -367,6 +367,7 @@ if(EXECUTORCH_BUILD_KERNELS_OPTIMIZED) "op_native_layer_norm_test.cpp" "op_neg_test.cpp" "op_sub_test.cpp" + "op_sum_test.cpp" "op_where_test.cpp" "UnaryUfuncRealHBBF16ToFloatHBF16Test.cpp" ${CMAKE_CURRENT_BINARY_DIR}/include/optimized/executorch/kernels/test/supported_features.cpp diff --git a/kernels/test/op_convolution_backward_test.cpp b/kernels/test/op_convolution_backward_test.cpp index 726048c43d5..5d441a99dc5 100644 --- a/kernels/test/op_convolution_backward_test.cpp +++ b/kernels/test/op_convolution_backward_test.cpp @@ -103,7 +103,9 @@ class OpConvolutionBackwardOutTest : public OperatorTest { 78, 12, 69, 2, 74, 56, 93, 39, 62, 22, 55, 67, 68, 74, 12, 69, 15, 73, 28, 70, 86, 20, 90, 49, 52, 26, 58, 2, 82, 17, 70, 55, 54, 83, 70, 11, 27, 9, 5, 42, 34, 62, 29, 94, 69, 81, 54, 4}; - std::vector expected_grad_input_data = { + // Some values are not exact in Half. GCC on aarch64 rejects them as + // narrowing in a braced Half list, so they are held as float. + std::vector expected_grad_input_data = { 1134, 7578, 686, 2682, 0, 4148, 7136, 2406, 8698, 0, 3759, 6003, 2163, 2395, 0, 2929, 5830, 3469, 6955, 0, 720, 6201, 495, 2063, 0, 5260, 5989, 3060, 7079, 0, @@ -146,7 +148,7 @@ class OpConvolutionBackwardOutTest : public OperatorTest { 6821, 19615, 4491, 13281, 0, 424, 1146, 999, 2906, 0, 15177, 15480, 8849, 12442, 0, 1222, 544, 2687, 1859, 0, 20215, 9693, 11441, 4964, 0, 1206, 555, 2466, 860, 0}; - std::vector expected_grad_weight_data = { + std::vector expected_grad_weight_data = { 9246, 22073, 12431, 19714, 11179, 19032, 8458, 6495, 18707, 13830, 20445, 17089, 17124, 18710, 11827, 17236, 16824, 9008, 14086, 18834, 17419, 16759, 13152, 9339, 13801, 20888, 13976, 27277, 13010, 23949, @@ -190,9 +192,15 @@ class OpConvolutionBackwardOutTest : public OperatorTest { grad_weight, grad_bias); - auto expected_grad_input = tf.make({2, 6, 7, 5}, expected_grad_input_data); - auto expected_grad_weight = - tf.make({4, 3, 4, 2}, expected_grad_weight_data); + auto expected_grad_input = tf.make( + {2, 6, 7, 5}, + std::vector( + expected_grad_input_data.begin(), expected_grad_input_data.end())); + auto expected_grad_weight = tf.make( + {4, 3, 4, 2}, + std::vector( + expected_grad_weight_data.begin(), + expected_grad_weight_data.end())); auto expected_grad_bias = tf.make({4}, expected_grad_bias_data); if (DTYPE == ScalarType::Half || DTYPE == ScalarType::BFloat16) { diff --git a/kernels/test/op_max_test.cpp b/kernels/test/op_max_test.cpp index 1bff0599d7c..e6cc5b39b04 100644 --- a/kernels/test/op_max_test.cpp +++ b/kernels/test/op_max_test.cpp @@ -254,7 +254,7 @@ class OpMaxUnaryOutTest : public OperatorTest { TensorFactory tf_in; Tensor input = tf_in.make({2, 0}, {}); Tensor out = tf_in.zeros({}); - Tensor expected = tf_in.make({}, {-INFINITY}); + Tensor expected = tf_in.make({}, {-std::numeric_limits::infinity()}); op_max_unary_out(input, out); EXPECT_TENSOR_CLOSE(out, expected); } diff --git a/kernels/test/op_min_test.cpp b/kernels/test/op_min_test.cpp index 89746ce7f56..ea6bd93456e 100644 --- a/kernels/test/op_min_test.cpp +++ b/kernels/test/op_min_test.cpp @@ -250,7 +250,7 @@ class OpMinUnaryOutTest : public OperatorTest { TensorFactory tf_in; Tensor input = tf_in.make({2, 0}, {}); Tensor out = tf_in.zeros({}); - Tensor expected = tf_in.make({}, {INFINITY}); + Tensor expected = tf_in.make({}, {std::numeric_limits::infinity()}); op_min_unary_out(input, out); EXPECT_TENSOR_CLOSE(out, expected); } diff --git a/kernels/test/op_scalar_tensor_test.cpp b/kernels/test/op_scalar_tensor_test.cpp index 676634fd429..945885835b7 100644 --- a/kernels/test/op_scalar_tensor_test.cpp +++ b/kernels/test/op_scalar_tensor_test.cpp @@ -138,7 +138,9 @@ TEST_F(OpScalarTensorOutTest, HalfSupport) { EXPECT_TENSOR_CLOSE(out, tf.make({}, {2.5})); op_scalar_tensor_out(INFINITY, out); - EXPECT_TENSOR_CLOSE(out, tf.make({}, {INFINITY})); + EXPECT_TENSOR_CLOSE( + out, + tf.make({}, {std::numeric_limits::infinity()})); } GENERATE_SCALAR_OVERFLOW_TESTS(OpScalarTensorOutTest) diff --git a/kernels/test/op_sum_test.cpp b/kernels/test/op_sum_test.cpp index ff71bcdb383..8cb7d34da84 100644 --- a/kernels/test/op_sum_test.cpp +++ b/kernels/test/op_sum_test.cpp @@ -305,6 +305,19 @@ class OpSumOutTest : public OperatorTest { op_sum_intlist_out(self, empty_dim_list, /*keepdim=*/false, dtype, out); EXPECT_TENSOR_CLOSE(out, tf_out.make({}, {56})); } + + template + void test_sum_dim_out_is_exact() { + TensorFactory tf; + // 2^24 + 1 is not representable in fp32, so an fp32 accumulator returns + // 2^24. + Tensor x = tf.make({2}, {16777216, 1}); + Tensor out = tf.zeros({}); + int64_t dim = 0; + op_sum_intlist_out( + x, ArrayRef{&dim, 1}, /*keepdim=*/false, /*dtype=*/{}, out); + EXPECT_TENSOR_EQ(out, tf.make({}, {16777217})); + } }; TEST_F(OpSumOutTest, BFloat16GenericPathAccumulatesInFloat) { @@ -336,6 +349,24 @@ TEST_F(OpSumOutTest, BFloat16LargeDimAccumulatesInFloat) { EXPECT_TENSOR_CLOSE(out, expected); } +TEST_F(OpSumOutTest, ZeroDimInputWithDim) { + TensorFactory tf; + Tensor self = tf.make({}, {5}); + for (int64_t dim : {0, -1}) { + for (bool keepdim : {false, true}) { + Tensor out = tf.zeros({}); + op_sum_intlist_out( + self, ArrayRef{&dim, 1}, keepdim, /*dtype=*/{}, out); + EXPECT_TENSOR_EQ(out, tf.make({}, {5})); + } + } +} + +TEST_F(OpSumOutTest, LongAndDoubleSumsAreExact) { + test_sum_dim_out_is_exact(); + test_sum_dim_out_is_exact(); +} + TEST_F(OpSumOutTest, InvalidDimensionListDies) { ET_SKIP_IF( torch::executor::testing::SupportedFeatures::get()->is_aten, diff --git a/kernels/test/targets.bzl b/kernels/test/targets.bzl index 4da702cf6be..863e2ae518c 100644 --- a/kernels/test/targets.bzl +++ b/kernels/test/targets.bzl @@ -358,7 +358,7 @@ def define_common_targets(): _common_op_test("op_squeeze_copy_test", ["aten", "portable"]) _common_op_test("op_stack_test", ["aten", "portable"]) _common_op_test("op_sub_test", ["aten", "portable", "optimized"]) - _common_op_test("op_sum_test", ["aten", "portable"]) + _common_op_test("op_sum_test", ["aten", "portable", "optimized"]) _common_op_test("op_t_copy_test", ["aten", "portable"]) _common_op_test("op_tan_test", ["aten", "portable"]) _common_op_test("op_tanh_test", ["aten", "portable"]) From 44ef4d2860ae546921365cc25661f2b2c19d7b4e Mon Sep 17 00:00:00 2001 From: Jacob Stevens Date: Wed, 4 Nov 2026 13:56:11 -0500 Subject: [PATCH 059/128] Prepack stored inputs of dtype conversions (#24435) Summary: ## Ulterior Motive Unblock stored-constant dtype conversions in native MobileNetV3 graphs. ## Rationale **What**: Add `aten._to_copy.default` to the existing stored-input prepack list. **Why**: A stored constant resolves to a TensorRef, but the conversion shader needs a bound tensor resource. Passing the reference through leaves its input descriptor unwritten. ## Details - Reuse the existing prepack insertion and consumer replacement. - Retain the existing stored-binding and mutation guards. - Add a structural prepack test and a numerical int64-to-float conversion test. Authored with AI using Metamate on agentcloud. Reviewed By: digantdesai Differential Revision: D129088168 Pull Request resolved: https://github.com/pytorch/executorch/pull/24435 --- .../runtime/vulkan/passes/InsertPrepack.cpp | 3 +- .../runtime/vulkan/test_insert_prepack.cpp | 9 ++ .../runtime/vulkan/test_vulkan_engine.cpp | 124 ++++++++++++++++++ 3 files changed, 135 insertions(+), 1 deletion(-) diff --git a/backends/native/runtime/vulkan/passes/InsertPrepack.cpp b/backends/native/runtime/vulkan/passes/InsertPrepack.cpp index 776cffea3a4..10ce15d3c8e 100644 --- a/backends/native/runtime/vulkan/passes/InsertPrepack.cpp +++ b/backends/native/runtime/vulkan/passes/InsertPrepack.cpp @@ -19,7 +19,8 @@ namespace ptn::vulkan { namespace { bool needs_prepack(const Node& node) { - constexpr std::array kOps{ + constexpr std::array kOps{ + "torch.ops.aten._to_copy.default", "torch.ops.aten.add.Tensor", "torch.ops.aten.index.Tensor", "torch.ops.aten.mul.Tensor", diff --git a/backends/native/test/runtime/vulkan/test_insert_prepack.cpp b/backends/native/test/runtime/vulkan/test_insert_prepack.cpp index 168d2955b58..8d92fafc886 100644 --- a/backends/native/test/runtime/vulkan/test_insert_prepack.cpp +++ b/backends/native/test/runtime/vulkan/test_insert_prepack.cpp @@ -98,6 +98,15 @@ TEST(InsertPrepackTest, PrepacksConstantSliceInput) { method.graph.node(2).input_value_ids(), std::vector({0, 4})); } +TEST(InsertPrepackTest, PrepacksConstantToCopyInput) { + Method method = make_method(); + method.graph.node(2).target = "torch.ops.aten._to_copy.default"; + + EXPECT_EQ(insert_prepack_nodes(method), 1); + EXPECT_EQ( + method.graph.node(2).input_value_ids(), std::vector({0, 4})); +} + TEST(InsertPrepackTest, LeavesSelfPrepackingOpUntouched) { Method method = make_method(); method.graph.node(2).target = "torch.ops.aten.linear.default"; diff --git a/backends/native/test/runtime/vulkan/test_vulkan_engine.cpp b/backends/native/test/runtime/vulkan/test_vulkan_engine.cpp index 78543924005..516ebf074ff 100644 --- a/backends/native/test/runtime/vulkan/test_vulkan_engine.cpp +++ b/backends/native/test/runtime/vulkan/test_vulkan_engine.cpp @@ -1370,6 +1370,109 @@ std::vector make_quantized_input_tensors() { return tensors; } +// sum = input + _to_copy(offsets, float), where `offsets` is a stored int64 +// constant. +std::vector make_stored_long_to_copy_program() { + flatbuffers::FlatBufferBuilder builder; + const auto float_meta = + create_tensor_meta(builder, fbs::ScalarType::FLOAT, {4}); + const auto long_meta = + create_tensor_meta(builder, fbs::ScalarType::LONG, {4}); + const std::vector> tensor_values = { + fbs::CreateTensorValueDirect(builder, "input", float_meta), + fbs::CreateTensorValueDirect(builder, "offsets", long_meta), + fbs::CreateTensorValueDirect(builder, "converted", float_meta), + fbs::CreateTensorValueDirect(builder, "sum", float_meta), + }; + const std::vector> input_outputs = { + fbs::CreateOutputDirect(builder, "input")}; + const std::vector> offsets_outputs = { + fbs::CreateOutputDirect(builder, "offsets")}; + const std::vector> converted_outputs = { + fbs::CreateOutputDirect(builder, "converted")}; + const std::vector> sum_outputs = { + fbs::CreateOutputDirect(builder, "sum")}; + const auto dtype = fbs::CreateScalarTypeArg(builder, fbs::ScalarType::FLOAT); + const std::vector> to_copy_inputs = { + fbs::CreateNamedArgumentDirect( + builder, "self", create_tensor_arg(builder, "offsets")), + fbs::CreateNamedArgumentDirect( + builder, + "dtype", + fbs::CreateArgument( + builder, fbs::ArgumentValue::ScalarTypeArg, dtype.Union())), + fbs::CreateNamedArgumentDirect( + builder, "layout", create_none_arg(builder)), + fbs::CreateNamedArgumentDirect( + builder, "device", create_none_arg(builder)), + fbs::CreateNamedArgumentDirect( + builder, "pin_memory", create_none_arg(builder)), + fbs::CreateNamedArgumentDirect( + builder, "non_blocking", create_bool_arg(builder, false)), + fbs::CreateNamedArgumentDirect( + builder, "memory_format", create_none_arg(builder)), + }; + const std::vector> add_inputs = { + fbs::CreateNamedArgumentDirect( + builder, "self", create_tensor_arg(builder, "input")), + fbs::CreateNamedArgumentDirect( + builder, "other", create_tensor_arg(builder, "converted")), + fbs::CreateNamedArgumentDirect( + builder, "alpha", create_int_arg(builder, 1)), + }; + const std::vector> output_inputs = { + fbs::CreateNamedArgumentDirect( + builder, "", create_tensor_arg(builder, "sum"))}; + const std::vector> nodes = { + fbs::CreateNodeDirect( + builder, + "input", + fbs::OpKind::PLACEHOLDER, + "", + nullptr, + &input_outputs), + fbs::CreateNodeDirect( + builder, + "offsets", + fbs::OpKind::PLACEHOLDER, + "", + nullptr, + &offsets_outputs), + fbs::CreateNodeDirect( + builder, + "converted", + fbs::OpKind::CALL_FUNCTION, + "torch.ops.aten._to_copy.default", + &to_copy_inputs, + &converted_outputs), + fbs::CreateNodeDirect( + builder, + "sum", + fbs::OpKind::CALL_FUNCTION, + "torch.ops.aten.add.Tensor", + &add_inputs, + &sum_outputs), + fbs::CreateNodeDirect( + builder, "output", fbs::OpKind::OUTPUT, "", &output_inputs), + }; + const auto graph = fbs::CreateGraph( + builder, + builder.CreateVector(nodes), + create_strings(builder, {"input"}), + create_strings(builder, {"sum"}), + builder.CreateVector(tensor_values)); + const std::vector> constants = { + fbs::CreateNamedTensorRefDirect( + builder, + "offsets", + "offsets", + long_meta, + fbs::InputKind::CONSTANT_TENSOR), + }; + return finish_program( + builder, graph, {fbs::CreateOutputSpecDirect(builder, "sum")}, constants); +} + constexpr int64_t kHfTokens = 4; constexpr int64_t kHfHeads = 4; constexpr int64_t kHfKvHeads = 2; @@ -2182,6 +2285,27 @@ TEST(VulkanEngineTest, ResizesBufferViewWithItsSource) { EXPECT_EQ(output, (std::vector{4.0f, 1.0f})); } +TEST(VulkanEngineTest, ConvertsStoredLongConstant) { + const std::string header = + R"({"offsets":{"dtype":"I64","shape":[4],"data_offsets":[0,32]}})"; + std::vector tensors = testing::make_safetensors(header, 32); + const std::vector offsets = {3, -2, 7, 100}; + std::memcpy( + tensors.data() + sizeof(uint64_t) + header.size(), + offsets.data(), + offsets.size() * sizeof(int64_t)); + CompiledProgram compiled = + compile_program(make_stored_long_to_copy_program(), std::move(tensors)); + const std::vector input = {0.5f, 1.5f, 2.5f, 3.5f}; + compiled.executable->set_input( + 0, input.data(), input.size(), ScalarType::Float); + compiled.executable->execute(); + std::vector output(4); + compiled.executable->get_output( + 0, output.data(), output.size(), ScalarType::Float); + EXPECT_EQ(output, (std::vector{3.5f, -0.5f, 9.5f, 103.5f})); +} + TEST(VulkanEngineTest, RejectsBufferViewResizeThatDoesNotDivide) { CompiledProgram compiled = compile_program(make_dynamic_buffer_view_index_program()); From 13be6a70ceb4246c8ec768380944e949c8a27d81 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Wed, 4 Nov 2026 11:00:00 -0800 Subject: [PATCH 060/128] Python runtime: fix lost, out-of-order and slow log lines (#24602) ## What is wrong today #24393 made the Python bindings stop calling Python from inside the runtime's log function, so a backend that logs while it holds a lock can no longer deadlock another thread. Log lines that cannot go to Python right away now wait, either for Python's main thread or for a writer thread that the bindings start. Some of these waiting lines are handled wrong: - **Scripts that only use the LLM runner lose log lines.** The bindings' library replaces the runtime's log function as soon as it loads. But the setup that function needs only ran when `portable_lib` was imported. `executorch.extension.llm.runner` loads the same library without importing `portable_lib`. So a `TextLLMRunner` script lost runtime lines inside `contextlib.redirect_stderr`, lost or delayed lines logged at exit, and often did not print the cause when it caught a runner error and then ended. - **A Python loop could slow down a lot.** On the main thread, with `sys.stderr` replaced, a loop could run hundreds of times slower while some of its log lines waited for the writer thread. - **Lines came out of order after an error.** On the main thread, with `sys.stderr` replaced, a binding call made in an `except` block right after a call that raised wrote its lines before the lines the failed call left waiting. - **Lines were dropped on a non-blocking stderr.** When stderr was non-blocking and full, the writer thread dropped its lines. - **The writer thread could split lines and touch Python after exit.** It wrote all its lines in one write, so on a pipe another thread's line could land inside one of them. It could also go back into Python after Python had finished exiting. ## What this change does **Keep waiting log lines in order without spinning.** Main-thread lines that would pass earlier waiting lines now go behind them. A call on the main thread first writes the lines that a call that raised left waiting, then its own. The writer thread waits for room when stderr is non-blocking and full. It writes a line at a time, so a line of normal length stays whole, and it stops without touching Python once exit has begun. The README now says which threads hand their lines over and which write them to stderr directly. **Set up log routing when only the LLM runner is imported.** Loading the library now schedules that setup for Python's main thread, which runs it at its next chance, because Python code cannot run while a library loads. Importing `portable_lib` still runs it as well. If the setup fails, its error is raised on the main thread, and importing `portable_lib` later runs the setup again. ## Tests New tests cover the order after a call that raised, the loop speed while lines wait, a non-blocking full stderr, and runner-only scripts (a redirect, and lines logged at exit). The order, loop-speed and runner-only tests fail without their fix. On Linux x86_64 with Python 3.12, CPU build: the runner-only modes pass 20 of 20 runs each with this change and fail every run with only the first commit. The log tests passed 4 of 5 runs. The other run hit the 120 second timeout of the deadlock test while the machine was heavily loaded, and that test's script then passed 49 of 49 direct runs in about 20 seconds each. Scripts that reproduced the #24393 deadlocks still finish. The rest of the pybindings tests show no new failures. The tests of the first commit also passed 10 of 10 runs on macOS. --- extension/pybindings/README.md | 39 +++-- extension/pybindings/pybindings.cpp | 165 ++++++++++++++----- extension/pybindings/test/test_pybindings.py | 160 +++++++++++++++++- 3 files changed, 306 insertions(+), 58 deletions(-) diff --git a/extension/pybindings/README.md b/extension/pybindings/README.md index 634560b6b0c..62ccff747c4 100644 --- a/extension/pybindings/README.md +++ b/extension/pybindings/README.md @@ -240,28 +240,33 @@ look wrong there. If Python has replaced `sys.stderr`, for example with pytest's `capsys`, `contextlib.redirect_stderr` or in a Jupyter notebook, the lines go to `sys.stderr` instead, a little later. The lines a binding call logs wait until the call returns. A line logged outside any binding call, for example by the LLM -runner, goes to `sys.stderr` before the next Python code runs if Python's main -thread logged it. A line from another thread goes there a moment later, from a -thread that the bindings start for this. Outside a call, only a thread that -holds the GIL can tell where `sys.stderr` goes, so a line from a thread that -does not hold it, such as one the LLM runner logs while it generates, goes to -the process's stderr. The lines wait rather than go to Python at once because a -backend can log while it holds a lock, and writing to Python at that moment -could deadlock with another thread. +runner, goes to `sys.stderr` soon after. If Python's main thread logged it, that +thread writes it itself, before the lines of its next binding call. But if +earlier lines of the main thread still wait for the bindings' thread, this line +follows them there. A line from another thread also goes to the bindings' +thread, which the bindings start for this. It writes to whatever `sys.stderr` is +at that moment. Outside a call, only a thread that holds the GIL can tell where +`sys.stderr` goes, so a line from a thread that does not hold it, such as one +the LLM runner logs while it generates, goes to the process's stderr. The lines +wait rather than go to Python at once because a backend can log while it holds a +lock, and writing to Python at that moment could deadlock with another thread. On Linux and macOS, a line also waits once the process's stderr is a pipe or socket that stays full for 100 ms, so a thread that holds the GIL does not hang on it while a Python thread in the same process empties it, as under `wurlitzer.pipes()`. The rest of a binding call's lines then wait for the call -to return. A thread that logs outside a call does not wait for room itself. It -hands the line to the bindings' thread, which writes it without the GIL. While -it writes, a binding call's lines wait for the call to return. A terminal is -written at once, since its reader is normally another process. The process can -still hang if another writer fills the process's stderr right after the check -for room, or if a Python thread in the same process reads a terminal that is the -process's stderr. On macOS, a pipe that has not grown past 16 KiB takes a line -only once the line fits in 512 bytes beside what the pipe holds, because macOS -reports room in such a pipe that it may not have. +to return. A thread that holds the GIL and logs outside a call does not wait for +room itself. It hands the line to the bindings' thread, which writes it a line +at a time without the GIL. While it writes, a binding call's lines wait for the +call to return. A terminal is written at once, since its reader is normally +another process. The process can still hang if another writer fills the +process's stderr right after the check for room, if a thread that does not hold +the GIL logs outside a call while a Python thread in the same process empties a +full stderr and a binding call holds the GIL waiting for that thread, or if a +Python thread in the same process reads a terminal that is the process's stderr. +On macOS, a pipe that has not grown past 16 KiB takes a line only once the line +fits in 512 bytes beside what the pipe holds, because macOS reports room in such +a pipe that it may not have. Up to 1 MiB of lines wait at a time; past that, about the newest 512 KiB to 1 MiB are kept, with a note of how many earlier bytes were left out. A fatal diff --git a/extension/pybindings/pybindings.cpp b/extension/pybindings/pybindings.cpp index 5ed76f3f626..17b4935a73d 100644 --- a/extension/pybindings/pybindings.cpp +++ b/extension/pybindings/pybindings.cpp @@ -181,8 +181,9 @@ struct DeferredLogs final { uint64_t batch = 1; uint64_t batches_written = 0; bool writer_running = false; - // A new thread state crashes a Python that has finished exiting, so exit - // waits while the writer takes the GIL with one. + // Exit waits while the writer takes the GIL, since a Python that has + // finished exiting crashes on a new thread state and may have freed the + // writer's saved one. bool writer_entering = false; bool exiting = false; }; @@ -222,6 +223,12 @@ void write_to_fd_2(const char* data, size_t size) { if (written < 0 && errno == EINTR) { continue; } + if (written < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) { + // fd 2 is non-blocking and full. + pollfd stderr_fd{2, POLLOUT, 0}; + poll(&stderr_fd, 1, -1); + continue; + } if (written <= 0) { return; } @@ -389,7 +396,20 @@ void write_logs_to_python(PendingLogs& logs) noexcept { if (logs.lines.empty()) { return; } - // A call's lines go after the lines this thread handed to the writer thread. + if (PyThread_get_thread_ident() == main_thread_id) { + // The lines that the main thread's pending call has yet to write come + // first, since a call that raised runs no pending call before the next. + DeferredLogs& deferred = deferred_logs(); + std::lock_guard lock(deferred.mutex); + note_left_out_bytes(deferred.main_thread_logs); + try { + logs.lines.insert(0, deferred.main_thread_logs.lines); + deferred.main_thread_logs = PendingLogs(); + deferred.main_thread_write_scheduled = false; + } catch (const std::bad_alloc&) { + } + } + // A call's lines go after the lines this thread handed over. if (logs.daemon || own_deferred_lines_wait()) { defer_lines(logs.lines); return; @@ -431,10 +451,38 @@ int write_main_thread_logs(void*) noexcept { return 0; } +// Writes the lines to fd 2 one at a time with the GIL released, so that a line +// no longer than PIPE_BUF stays whole beside other threads' lines. Returns +// false, without the GIL, if Python began to exit meanwhile: Python may then +// free this thread's state, so the thread must not touch Python again. +bool write_lines_to_fd_2_without_gil(const std::string& lines) { + PyThreadState* const state = PyEval_SaveThread(); + size_t begin = 0; + while (begin < lines.size()) { + const size_t end = lines.find('\n', begin) + 1; + write_to_fd_2(lines.data() + begin, end - begin); + begin = end; + } + DeferredLogs& deferred = deferred_logs(); + { + std::lock_guard lock(deferred.mutex); + if (deferred.exiting) { + return false; + } + deferred.writer_entering = true; + } + PyEval_RestoreThread(state); + std::lock_guard lock(deferred.mutex); + deferred.writer_entering = false; + deferred.writer_changed.notify_all(); + return true; +} + // Writes through Python's C API only, and is not noexcept, since Python may end // this thread while it waits for the GIL during exit, and the unwinding must -// neither touch a Python object nor stop at a noexcept frame. -void write_lines_through_python(const PendingLogs& logs) { +// neither touch a Python object nor stop at a noexcept frame. Returns false as +// write_lines_to_fd_2_without_gil does. +bool write_lines_through_python(const PendingLogs& logs) { PyObject* const text = PyUnicode_DecodeUTF8( logs.lines.data(), logs.lines.size(), "backslashreplace"); PyObject* const stream = PySys_GetObject("stderr"); @@ -444,12 +492,11 @@ void write_lines_through_python(const PendingLogs& logs) { const bool written = result != nullptr; Py_XDECREF(result); Py_XDECREF(text); - if (!written) { - PyErr_Clear(); - PyThreadState* const state = PyEval_SaveThread(); - write_to_fd_2(logs.lines.data(), logs.lines.size()); - PyEval_RestoreThread(state); + if (written) { + return true; } + PyErr_Clear(); + return write_lines_to_fd_2_without_gil(logs.lines); } // Runs on a thread of its own until no line waits. @@ -476,14 +523,16 @@ void write_deferred_logs() { deferred.writer_changed.notify_all(); } note_left_out_bytes(logs); - if (stderr_is_process_stderr()) { - // Waits for room without the GIL or any lock of Python's, so that a full - // fd 2 never holds up Python's exit. - PyThreadState* const state = PyEval_SaveThread(); - write_to_fd_2(logs.lines.data(), logs.lines.size()); - PyEval_RestoreThread(state); - } else { - write_lines_through_python(logs); + // Lines for the process's stderr wait for room in fd 2 without the GIL or + // any lock of Python's, so that a full fd 2 never holds up Python's exit. + const bool python_still_running = stderr_is_process_stderr() + ? write_lines_to_fd_2_without_gil(logs.lines) + : write_lines_through_python(logs); + if (!python_still_running) { + std::lock_guard lock(deferred.mutex); + deferred.writer_running = false; + deferred.writer_changed.notify_all(); + return; } PyGILState_Release(gil); std::lock_guard lock(deferred.mutex); @@ -508,8 +557,10 @@ void defer_lines(const std::string& lines) noexcept { // The main thread logged these lines from native code, which is inside a // sys.stderr.write only if that write runs such code. So it can write them // before its next bytecode, while a short redirect of sys.stderr still - // lasts. Its earlier lines stay ahead. + // lasts. While the writer thread holds earlier lines of it, these follow + // them there instead, since keeping a thread's order beats the redirect. if (PyThread_get_thread_ident() == main_thread_id && + last_deferred_batch <= deferred.batches_written && (deferred.main_thread_write_scheduled || !sys_stderr_is_checked_fd_2())) { keep_line(deferred.main_thread_logs, lines); @@ -539,6 +590,8 @@ bool deferred_lines_wait() { !deferred.exiting; } +// Whether lines this thread handed over still wait, which its later lines must +// not pass. bool own_deferred_lines_wait() { DeferredLogs& deferred = deferred_logs(); std::lock_guard lock(deferred.mutex); @@ -582,6 +635,54 @@ void write_deferred_logs_at_exit() noexcept { } } +// Sets up what the routing of runtime logs relies on, once, with the GIL held. +// A setup that raised runs again on the next call. +void set_up_log_routing() { + static bool set_up = false; + if (set_up) { + return; + } + // The Python code below can let another thread get here meanwhile. + set_up = true; + try { + // A line logged outside any call compares sys.stderr with the stream + // checked last, so the first check cannot wait for the first call. + stderr_is_process_stderr(); + main_thread_id = py::module_::import("threading") + .attr("main_thread")() + .attr("ident") + .cast(); + py::module_::import("atexit").attr("register")( + py::cpp_function([]() { write_deferred_logs_at_exit(); })); + } catch (...) { + set_up = false; + throw; + } +#ifndef _WIN32 + // A child of fork() has no writer thread, the waiting lines are the + // parent's, and the thread that forked is its main thread. + pthread_atfork(nullptr, nullptr, [] { + deferred_logs_state() = new DeferredLogs(); + main_thread_id = PyThread_get_thread_ident(); + last_deferred_batch = 0; + }); +#endif +} + +// Runs as a pending call on Python's main thread, which raises the error that a +// failed setup leaves, such as a KeyboardInterrupt. +int set_up_log_routing_after_load(void*) noexcept { + try { + set_up_log_routing(); + return 0; + } catch (py::error_already_set& error) { + error.restore(); + } catch (const std::exception& error) { + PyErr_SetString(PyExc_RuntimeError, error.what()); + } + return -1; +} + std::string with_runtime_errors(const char* message) { std::string result(message); if (runtime_errors_size != 0) { @@ -5356,24 +5457,7 @@ PYBIND11_MODULE(EXECUTORCH_PYTHON_MODULE_NAME, m) { // It calls Python as it starts and may write to Python as it ends, so it // needs the GIL and never follows a py::gil_scoped_release guard. auto call_guard = py::call_guard(); - // A line logged outside any call compares sys.stderr with the stream checked - // last, so the first check cannot wait for the first call. - stderr_is_process_stderr(); - main_thread_id = py::module_::import("threading") - .attr("main_thread")() - .attr("ident") - .cast(); - py::module_::import("atexit").attr("register")( - py::cpp_function([]() { write_deferred_logs_at_exit(); })); -#ifndef _WIN32 - // A child of fork() has no writer thread, the waiting lines are the - // parent's, and the thread that forked is its main thread. - pthread_atfork(nullptr, nullptr, [] { - deferred_logs_state() = new DeferredLogs(); - main_thread_id = PyThread_get_thread_ident(); - last_deferred_batch = 0; - }); -#endif + set_up_log_routing(); #if defined(EXECUTORCH_PYBINDINGS_CUDA) && !defined(USE_ATEN_LIB) // Back executorch.runtime.cuda, which keeps the runtime free of PyTorch. @@ -5880,6 +5964,13 @@ runtime::PalImpl build_pal() { // Update PAL to redirect logs. ET_UNUSED bool registration_result = runtime::register_pal(build_pal()); +// The runtime logs through emit_log_message from the moment this library +// loads, and another module, such as the LLM runner's, can load it without +// importing this one. Python code must not run while a library loads, so +// Python's main thread sets up the routing at its next chance. +ET_UNUSED bool log_routing_scheduled = Py_IsInitialized() && + Py_AddPendingCall(&set_up_log_routing_after_load, nullptr) == 0; + } // namespace } // namespace pybindings diff --git a/extension/pybindings/test/test_pybindings.py b/extension/pybindings/test/test_pybindings.py index b212abafdc8..74d719be0a7 100644 --- a/extension/pybindings/test/test_pybindings.py +++ b/extension/pybindings/test/test_pybindings.py @@ -622,6 +622,7 @@ def test_runtime_logs_outside_a_call_reach_sys_stderr(self): self.skipTest("the script below uses the portable bindings") script = """ +import atexit import contextlib import io import os @@ -633,7 +634,9 @@ def test_runtime_logs_outside_a_call_reach_sys_stderr(self): if sys.argv[1] == "replaced before import": # As in a notebook, which replaces sys.stderr before any import. sys.stderr = captured -from executorch.extension.pybindings import portable_lib +if not sys.argv[1].startswith("runner only"): + # The LLM runner loads the library of these bindings without importing them. + from executorch.extension.pybindings import portable_lib try: from executorch.extension.llm.runner import TextLLMRunner @@ -643,6 +646,19 @@ def test_runtime_logs_outside_a_call_reach_sys_stderr(self): sys.exit() missing_program = os.path.join(os.path.dirname(sys.argv[2]), "missing.pte") +if sys.argv[1] == "runner only, at exit": + + def log_at_exit(): + try: + TextLLMRunner(missing_program, sys.argv[2]) + except RuntimeError: + pass + print("logged at exit", file=sys.stderr) + + # Python is exiting, so the runner's lines cannot wait for another thread. + atexit.register(log_at_exit) + sys.exit() + if sys.argv[1] == "replaced, another thread": sys.stderr = captured @@ -673,9 +689,31 @@ def log_twice(): print("in order:", 0 <= first < text.find("Program identifier")) sys.exit() +if sys.argv[1] == "replaced, a call after a failed load": + in_order = True + # The first round comes out in order even without the wait, so it repeats. + for _ in range(5): + captured = io.StringIO() + sys.stderr = captured + try: + TextLLMRunner(missing_program, sys.argv[2]) + except RuntimeError: + # A call that raised runs no pending call before the next call, yet + # this call's line must come after the runner's lines. + try: + portable_lib._load_program_from_buffer(b"x" * 128) + except RuntimeError: + pass + sys.stderr = sys.__stderr__ + text = captured.getvalue() + first = text.find("[llm_runner_helper.cpp:") + in_order = in_order and 0 <= first < text.find("Program identifier") + print("in order:", in_order) + sys.exit() + with ( contextlib.redirect_stderr(captured) - if sys.argv[1] == "replaced after import" + if sys.argv[1].endswith("replaced after import") else contextlib.nullcontext() ): try: @@ -695,7 +733,10 @@ def log_twice(): "replaced before import", "replaced after import", "replaced, another thread", + "replaced, a call after a failed load", "process stderr", + "runner only, replaced after import", + "runner only, at exit", ): completed = subprocess.run( [sys.executable, "-c", script, mode, tokenizer], @@ -710,8 +751,14 @@ def log_twice(): self.skipTest("the LLM runner bindings are not built") if mode == "process stderr": self.assertIn("[llm_runner_helper.cpp:", completed.stderr) - elif mode == "replaced, another thread": - self.assertIn("in order: True", completed.stdout) + elif mode == "runner only, at exit": + last = completed.stderr.rfind("[llm_runner_helper.cpp:") + self.assertTrue( + 0 <= last < completed.stderr.find("logged at exit"), + completed.stderr[-2000:], + ) + elif mode.startswith("replaced, a"): + self.assertIn("in order: True", completed.stdout, mode) else: self.assertIn("lines through Python:", completed.stdout, mode) self.assertNotIn("lines through Python: 0", completed.stdout, mode) @@ -1042,6 +1089,111 @@ def build_runners(): if "no LLM runner" not in completed.stdout: self.assertIn("done", completed.stdout, f"{mode}, {kind}") + def test_lines_waiting_for_a_full_stderr(self): + if self.kernel_mode != "portable": + self.skipTest("the script below uses the portable bindings") + if sys.platform == "win32": + self.skipTest("uses a POSIX pipe as fd 2") + + script = """ +import io +import os +import sys +import threading +import time + +from executorch.extension.pybindings import portable_lib + +try: + from executorch.extension.llm.runner import TextLLMRunner +except (ImportError, RuntimeError): + # The package raises RuntimeError when its extension is not built. + print("no LLM runner") + sys.exit() +missing_program = os.path.join(os.path.dirname(sys.argv[2]), "missing.pte") + + +def spin(seconds): + rounds = 0 + end = time.perf_counter() + seconds + while time.perf_counter() < end: + for _ in range(100): + rounds += 1 + return rounds + + +normal_speed = spin(0.5) +read_end, write_end = os.pipe() +os.set_blocking(write_end, False) +for size in (4096, 512, 64, 1): + try: + while True: + os.write(write_end, b"x" * size) + except BlockingIOError: + pass +saved_stderr = os.dup(2) +os.dup2(write_end, 2) +if sys.argv[1] == "blocking": + os.set_blocking(2, True) +drained = bytearray() + + +def drain(): + # Starts once the lines below wait, and needs the GIL after every read. + time.sleep(2) + os.set_blocking(read_end, False) + end = time.monotonic() + 3 + while time.monotonic() < end: + try: + drained.extend(os.read(read_end, 65536)) + except BlockingIOError: + time.sleep(0.01) + + +drainer = threading.Thread(target=drain) +drainer.start() +try: + # fd 2 is full, so these lines go to a thread of the bindings, which waits + # to write them. + try: + TextLLMRunner(missing_program, sys.argv[2]) + except RuntimeError: + pass + if sys.argv[1] == "blocking": + # The main thread's later lines must wait behind those without making + # it spin. + time.sleep(0.3) + sys.stderr = io.StringIO() + try: + portable_lib._load_program_from_buffer(b"x" * 128) + except RuntimeError: + pass + print("speed:", round(spin(0.5) / normal_speed, 3)) + sys.stderr = sys.__stderr__ + drainer.join() +finally: + os.dup2(saved_stderr, 2) +print("runner lines:", drained.count(b"[llm_runner_helper.cpp:")) +""" + with tempfile.TemporaryDirectory() as directory: + tokenizer = write_llm_tokenizer(directory) + for mode in ("blocking", "non-blocking"): + completed = subprocess.run( + [sys.executable, "-c", script, mode, tokenizer], + capture_output=True, + text=True, + timeout=120, + ) + self.assertEqual( + completed.returncode, 0, f"{mode}: {completed.stderr[-2000:]}" + ) + if "no LLM runner" in completed.stdout: + self.skipTest("the LLM runner bindings are not built") + self.assertNotIn("runner lines: 0", completed.stdout, mode) + if mode == "blocking": + speed = float(completed.stdout.split("speed:")[1].split()[0]) + self.assertGreater(speed, 0.05, completed.stdout) + def test_deferred_runtime_logs_do_not_nest_in_a_stderr_write(self): if self.kernel_mode != "portable": self.skipTest("the script below uses the portable bindings") From 6c2d8172283a6bcccde6f43192984aa37ff6be3b Mon Sep 17 00:00:00 2001 From: RJ Ascani Date: Wed, 4 Nov 2026 11:32:59 -0800 Subject: [PATCH 061/128] Arm setup: let CI jobs skip the FVPs they don't run (#24584) examples/arm/setup.sh installs the Corstone-300, Corstone-320 and Corstone-1000 FVPs whenever the Ethos-U dependencies are enabled, about 360 MB from developer.arm.com per job. On 2026-11-02 about 3,300 CI job legs did this, roughly 1.2 TB from Arm's CDN in one day, and most of them never start an FVP. setup.sh gains a repeatable --fvp option, mirroring --target-toolchain, and a --disable-fvps option. The default becomes Corstone-300 and Corstone-320: nothing in CI uses Corstone-1000, which only the manual Direct Drive flow needs, so the Arm README now selects it there explicitly. Each selected FVP also puts its own python/lib on LD_LIBRARY_PATH. The models link against that libpython, and Corstone-300 only found it through the hard-coded Corstone-320 path before. The jobs then ask for less. The TOSA and VGF flows in test_backend.sh skip the Ethos-U dependencies, as the equivalent pull.yml jobs already do. The Cortex-M jobs only install Corstone-300. The pico2 and CMSIS pack builds, the Zephyr size test and the public API compatibility job keep the toolchain but install no FVP. The Ethos-U and Zephyr jobs keep the default even though most legs use one of the two: a missing FVP turns their tests into xfails or report entries rather than job failures, so narrowing them risks losing coverage silently, and the next commit's cache removes the download anyway. Authored with Claude Code. cc @digantdesai @freddan80 @per @zingo @oscarandersson8218 @mansnils @Sebastian-Larsson @robell --- .ci/scripts/test_backend.sh | 10 +++--- .github/workflows/_test_cortex_m_e2e.yml | 2 +- .github/workflows/_test_cortex_m_ops.yml | 2 +- .github/workflows/build-cmsis-pack.yml | 2 +- .github/workflows/pull.yml | 7 ++-- .github/workflows/test-pico2-build.yml | 4 +-- backends/arm/README.md | 5 +-- backends/arm/scripts/fvp_utils.sh | 10 ++---- examples/arm/setup.sh | 41 +++++++++++++++++++++++- 9 files changed, 61 insertions(+), 22 deletions(-) diff --git a/.ci/scripts/test_backend.sh b/.ci/scripts/test_backend.sh index a6dbeef9b12..80d50ab9310 100755 --- a/.ci/scripts/test_backend.sh +++ b/.ci/scripts/test_backend.sh @@ -76,11 +76,13 @@ if [[ "$FLOW" == *arm* ]]; then PYTEST_RETRY_ARGS=(--reruns 2 --reruns-delay 1) fi - # Setup ARM deps. + # Setup ARM deps. Only the Ethos-U flows run on an FVP. if [[ "$FLOW" == *vgf* ]]; then - .ci/scripts/setup-arm-baremetal-tools.sh --enable-mlsdk-deps --install-mlsdk-deps-with-pip - else + .ci/scripts/setup-arm-baremetal-tools.sh --disable-ethos-u-deps --enable-mlsdk-deps --install-mlsdk-deps-with-pip + elif [[ "$FLOW" == *ethos_u* ]]; then .ci/scripts/setup-arm-baremetal-tools.sh + else + .ci/scripts/setup-arm-baremetal-tools.sh --disable-ethos-u-deps fi source examples/arm/arm-scratch/setup_path.sh @@ -98,7 +100,7 @@ fi if [[ "$FLOW" == *cortex_m* ]]; then # Cortex-M runs on the Corstone-300 FVP, using the same Arm toolchain as the # Ethos-U flows but its own semihosting runner. - .ci/scripts/setup-arm-baremetal-tools.sh + .ci/scripts/setup-arm-baremetal-tools.sh --fvp corstone300 source examples/arm/arm-scratch/setup_path.sh backends/cortex_m/test/build_test_runner.sh diff --git a/.github/workflows/_test_cortex_m_e2e.yml b/.github/workflows/_test_cortex_m_e2e.yml index 1957a2b0c56..9f525c8bb7a 100644 --- a/.github/workflows/_test_cortex_m_e2e.yml +++ b/.github/workflows/_test_cortex_m_e2e.yml @@ -54,7 +54,7 @@ jobs: install_executorch "--use-pt-pinned-commit" # Install arm dependencies - .ci/scripts/setup-arm-baremetal-tools.sh + .ci/scripts/setup-arm-baremetal-tools.sh --fvp corstone300 source examples/arm/arm-scratch/setup_path.sh # Export and run model on FVP (run.sh internally builds the test runner). diff --git a/.github/workflows/_test_cortex_m_ops.yml b/.github/workflows/_test_cortex_m_ops.yml index 885f5fb64d9..a09f31fd072 100644 --- a/.github/workflows/_test_cortex_m_ops.yml +++ b/.github/workflows/_test_cortex_m_ops.yml @@ -48,7 +48,7 @@ jobs: install_executorch "--use-pt-pinned-commit" # Install arm dependencies - .ci/scripts/setup-arm-baremetal-tools.sh + .ci/scripts/setup-arm-baremetal-tools.sh --fvp corstone300 source examples/arm/arm-scratch/setup_path.sh # Build the runner for this target (written to a target-suffixed dir diff --git a/.github/workflows/build-cmsis-pack.yml b/.github/workflows/build-cmsis-pack.yml index bdeb75ba32e..6af98874e7d 100644 --- a/.github/workflows/build-cmsis-pack.yml +++ b/.github/workflows/build-cmsis-pack.yml @@ -75,7 +75,7 @@ jobs: echo "::endgroup::" echo "::group::Install ARM toolchain" - .ci/scripts/setup-arm-baremetal-tools.sh + .ci/scripts/setup-arm-baremetal-tools.sh --disable-fvps source examples/arm/arm-scratch/setup_path.sh echo "::endgroup::" diff --git a/.github/workflows/pull.yml b/.github/workflows/pull.yml index 8a7494f02e3..10bc81afac3 100644 --- a/.github/workflows/pull.yml +++ b/.github/workflows/pull.yml @@ -734,11 +734,12 @@ jobs: cxx_flags="-fno-exceptions -fno-rtti -Wall -Werror -Wno-int-in-bool-context -DET_HAVE_PREAD=0" setup_script_args="" if [[ ${{ matrix.os}} == "bare_metal" ]]; then + setup_script_args="--fvp corstone300" toolchain_prefix=arm-none-eabi- threshold="35000" # GCC 15.2, minimal PAL + nano baseline: 32180 bytes, 2026-10-28 toolchain_cmake=examples/arm/ethos-u-setup/arm-none-eabi-gcc.cmake elif [[ ${{ matrix.os}} == "zephyr-preset" ]]; then - setup_script_args="--target-toolchain zephyr" + setup_script_args="--target-toolchain zephyr --disable-fvps" toolchain_prefix=arm-zephyr-eabi- threshold="50000" # Zephyr SDK 1.0.1, minimal PAL baseline: 46112 bytes, 2026-10-28 toolchain_cmake=examples/zephyr/x86_64-linux-arm-zephyr-eabi-gcc.cmake @@ -835,7 +836,7 @@ jobs: install_executorch "--use-pt-pinned-commit" # Install arm dependencies - .ci/scripts/setup-arm-baremetal-tools.sh + .ci/scripts/setup-arm-baremetal-tools.sh --fvp corstone300 source examples/arm/arm-scratch/setup_path.sh # To build cortex-m test runner @@ -1113,7 +1114,7 @@ jobs: source .ci/scripts/utils.sh install_executorch "--use-pt-pinned-commit" - .ci/scripts/setup-arm-baremetal-tools.sh --enable-mlsdk-deps --install-mlsdk-deps-with-pip + .ci/scripts/setup-arm-baremetal-tools.sh --disable-fvps --enable-mlsdk-deps --install-mlsdk-deps-with-pip source examples/arm/arm-scratch/setup_path.sh backends/arm/scripts/public_api_manifest/validate_all_public_api_manifests.sh diff --git a/.github/workflows/test-pico2-build.yml b/.github/workflows/test-pico2-build.yml index 01f6af9fa2e..d6c91544685 100644 --- a/.github/workflows/test-pico2-build.yml +++ b/.github/workflows/test-pico2-build.yml @@ -45,7 +45,7 @@ jobs: install_executorch "--use-pt-pinned-commit" # Install ARM baremetal toolchain - .ci/scripts/setup-arm-baremetal-tools.sh + .ci/scripts/setup-arm-baremetal-tools.sh --disable-fvps source examples/arm/arm-scratch/setup_path.sh echo "=== Step 1: Export FP32 model ===" @@ -116,7 +116,7 @@ jobs: install_executorch "--use-pt-pinned-commit" # Install ARM baremetal toolchain - .ci/scripts/setup-arm-baremetal-tools.sh + .ci/scripts/setup-arm-baremetal-tools.sh --disable-fvps source examples/arm/arm-scratch/setup_path.sh echo "=== Step 1: Export INT8 CMSIS-NN model ===" diff --git a/backends/arm/README.md b/backends/arm/README.md index 73f436962f8..84fb38a81d6 100644 --- a/backends/arm/README.md +++ b/backends/arm/README.md @@ -328,7 +328,7 @@ https://corstone1000.docs.arm.com/en/corstone1000-2025.12/ Setup: ``` -./examples/arm/setup.sh --i-agree-to-the-contained-eula --target-toolchain linux-musl +./examples/arm/setup.sh --i-agree-to-the-contained-eula --target-toolchain linux-musl --fvp corstone1000 source ./examples/arm/arm-scratch/setup_path.sh ``` @@ -340,7 +340,8 @@ Build: --build_type=Debug ``` -Note: setup selects the linux-musl toolchain; build uses the aarch64-linux-musl GCC toolchain name. +Note: setup selects the linux-musl toolchain and the Corstone-1000 FVP, which is +not installed by default; build uses the aarch64-linux-musl GCC toolchain name. If your Yocto image enables the dropbear SSH server, you can copy the `executor_runner` binary into the running FVP via scp: diff --git a/backends/arm/scripts/fvp_utils.sh b/backends/arm/scripts/fvp_utils.sh index 21834eb92e9..be747a30570 100644 --- a/backends/arm/scripts/fvp_utils.sh +++ b/backends/arm/scripts/fvp_utils.sh @@ -63,9 +63,7 @@ function install_fvp() { return 0 fi - # Download and install Corstone FVP simulator platforms. - fvps=("corstone300" "corstone320" "corstone1000") - + # Download and install the Corstone FVP simulator platforms selected in setup.sh. for fvp in "${fvps[@]}"; do cd "${root_dir}" if [[ ! -e "FVP_${fvp}.tgz" ]]; then @@ -140,17 +138,15 @@ function setup_path_fvp() { return 0 fi - fvps=("corstone300" "corstone320" "corstone1000") for fvp in "${fvps[@]}"; do model_dir_variable=${fvp}_model_dir fvp_model_dir=${!model_dir_variable} fvp_bin_path="${root_dir}/FVP-${fvp}/models/${fvp_model_dir}" append_env_in_setup_path PATH ${fvp_bin_path} + # The models link against the libpython shipped with each FVP + append_env_in_setup_path LD_LIBRARY_PATH "${root_dir}/FVP-${fvp}/python/lib/" done - # Fixup for Corstone-320 python dependency - append_env_in_setup_path LD_LIBRARY_PATH "${root_dir}/FVP-corstone320/python/lib/" - local fvp_command for fvp_command in \ FVP_Corstone_SSE-300_Ethos-U55 \ diff --git a/examples/arm/setup.sh b/examples/arm/setup.sh index e3201f993e7..1a76871b0de 100755 --- a/examples/arm/setup.sh +++ b/examples/arm/setup.sh @@ -25,6 +25,7 @@ enable_baremetal_toolchain=1 target_toolchain="" target_toolchains=() enable_fvps=1 +fvps=() enable_vela=1 enable_model_converter=0 # model-converter tool for VGF output enable_vgf_lib=0 # vgf reader - runtime backend dependency @@ -52,6 +53,8 @@ OPTION_LIST=( "--enable-baremetal-toolchain Enable baremetal toolchain setup" "--target-toolchain Select toolchain: gnu (default), zephyr, or linux-musl. Repeat the option to install multiple toolchains." "--enable-fvps Enable FVP setup" + "--disable-fvps Disable FVP setup" + "--fvp Select FVP instead of the default corstone300 and corstone320: corstone300, corstone320, or corstone1000. Repeat the option to install multiple." "--enable-vela Enable VELA setup" "--enable-model-converter Enable MLSDK model converter setup" "--enable-vgf-lib Enable MLSDK vgf library setup" @@ -126,6 +129,18 @@ function check_options() { enable_fvps=1 shift ;; + --disable-fvps) + enable_fvps=0 + shift + ;; + --fvp) + if [[ $# -lt 2 ]]; then + print_usage "$@" + exit 1 + fi + add_fvp "$2" + shift 2 + ;; --enable-vela) enable_vela=1 shift @@ -223,6 +238,22 @@ function add_target_toolchain() { target_toolchains+=("${toolchain}") } +function add_fvp() { + local fvp=$1 + if [[ "${fvp}" != "corstone300" && "${fvp}" != "corstone320" && "${fvp}" != "corstone1000" ]]; then + echo "Error: Unsupported FVP '${fvp}'. Valid options are corstone300, corstone320, corstone1000." >&2 + exit 1 + fi + + local selected_fvp + for selected_fvp in ${fvps[@]+"${fvps[@]}"}; do + if [[ "${selected_fvp}" == "${fvp}" ]]; then + return + fi + done + fvps+=("${fvp}") +} + function setup_root_dir() { mkdir -p "${root_dir}" root_dir=$(realpath "${root_dir}") @@ -389,6 +420,14 @@ if [[ $is_script_sourced -eq 0 ]]; then fi target_toolchains_display="$(IFS=,; echo "${target_toolchains[*]}")" + if [[ "${#fvps[@]}" -eq 0 ]]; then + fvps=("corstone300" "corstone320") + elif [[ "${enable_fvps}" -eq 0 ]]; then + echo "Error: --fvp cannot be combined with disabled FVP setup." >&2 + exit 1 + fi + fvps_display="$(IFS=,; echo "${fvps[*]}")" + # Import utils source $et_dir/backends/arm/scripts/fvp_utils.sh source $et_dir/backends/arm/scripts/toolchain_utils.sh @@ -424,7 +463,7 @@ if [[ $is_script_sourced -eq 0 ]]; then # Setup FVP if [[ "${enable_fvps}" -eq 1 ]]; then - log_step "fvp" "Setting up Arm Fixed Virtual Platforms" + log_step "fvp" "Setting up Arm Fixed Virtual Platforms: ${fvps_display}" if [[ "${OS}" == "Linux" ]]; then check_fvp_eula install_fvp From c4f5e66e9724134b2a2a0debaf97adcbb156e4c3 Mon Sep 17 00:00:00 2001 From: Oscar Andersson <87121123+oscarandersson8218@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:34:34 +0100 Subject: [PATCH 062/128] Arm backend: Calibrate ranges before QAT in example (#24611) Moving-average observers shared across tensors can narrow activation ranges and introduce clipping during QAT. Update the QAT example to calibrate histogram activation observers with fake quantization disabled. Freeze activation and weight observers, then enable fake quantization for weight training. This changes the example's training recipe; backend quantizer defaults remain unchanged. cc @digantdesai @freddan80 @per @zingo @mansnils @Sebastian-Larsson @robell @rascani Signed-off-by: Oscar Andersson --- examples/arm/QAT_example/README.md | 7 ++++ examples/arm/QAT_example/qat_loop.py | 49 ++++++++++++++++++++++++++-- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/examples/arm/QAT_example/README.md b/examples/arm/QAT_example/README.md index fb851b4b329..5d88dca6e84 100644 --- a/examples/arm/QAT_example/README.md +++ b/examples/arm/QAT_example/README.md @@ -60,6 +60,13 @@ range consistent across the graph avoids requantization boundaries around the fused flow-offset samplers while ensuring sampled images never use the ambiguous SNORM value `-128`. +QAT first calibrates on the selected training inputs with fake quantization +disabled. Histogram observers accumulate the combined distribution of tensors +that share quantization parameters. Moving-average observers can instead +narrow the shared range toward individual tensors and cause clipping. After +calibration, observers are frozen and fake quantization is enabled for weight +training, so the calibrated ranges remain fixed throughout QAT. + ## Smoke Runs Small random or short QAT runs are useful only for checking that the flow runs. diff --git a/examples/arm/QAT_example/qat_loop.py b/examples/arm/QAT_example/qat_loop.py index 25ff753526f..4f1ecf2482e 100644 --- a/examples/arm/QAT_example/qat_loop.py +++ b/examples/arm/QAT_example/qat_loop.py @@ -28,7 +28,7 @@ import re import sys from collections import Counter -from dataclasses import dataclass +from dataclasses import dataclass, replace from pathlib import Path from typing import Any, cast, Iterable, Sequence @@ -41,12 +41,21 @@ move_exported_model_to_eval, move_exported_model_to_train, ) +from torchao.quantization.pt2e.fake_quantize import ( + disable_fake_quant, + disable_observer, + enable_fake_quant, + FakeQuantize, +) +from torchao.quantization.pt2e.observer import HistogramObserver from torchao.quantization.pt2e.quantize_pt2e import ( convert_pt2e, prepare_pt2e, prepare_qat_pt2e, ) +from torchao.quantization.pt2e.quantizer import QuantizationSpec + EXECUTORCH_ROOT = Path(__file__).resolve().parents[3] sys.path.insert(0, str(EXECUTORCH_ROOT)) @@ -60,6 +69,9 @@ get_vgf_snorm_quantization_config, VgfQuantizer, ) +from executorch.backends.arm.quantizer.quantization_config import ( # noqa: E402 + QuantizationConfig, +) from executorch.backends.arm.vgf import VgfCompileSpec, VgfPartitioner # noqa: E402 from executorch.exir import EdgeCompileConfig, to_edge_transform_and_lower # noqa: E402 from executorch.exir.passes.quantize_io_pass import ( # noqa: E402 @@ -671,6 +683,25 @@ def quantization_coverage(graph_module: torch.nn.Module) -> dict[str, Any]: } +def histogram_qat_config(config: QuantizationConfig) -> QuantizationConfig: + """Use cumulative activation ranges for tensors sharing QAT observers. + + Args: + config (QuantizationConfig): QAT configuration to customize. + + Returns: + QuantizationConfig: Configuration retaining weight and bias policies. + + """ + activation = replace( + cast(QuantizationSpec, config.input_activation), + observer_or_fake_quant_ctr=FakeQuantize.with_args( + observer=HistogramObserver.with_args(eps=2**-16), + ), + ) + return replace(config, input_activation=activation, output_activation=activation) + + def make_quantizer( *, is_qat: bool, @@ -680,14 +711,19 @@ def make_quantizer( VgfCompileSpec, VgfCompileSpec()._set_preserve_io_quantization(True) ) quantizer = VgfQuantizer(compile_spec, use_composable_quantizer=True) - global_config = get_vgf_snorm_quantization_config(is_qat=is_qat) + global_config: QuantizationConfig = get_vgf_snorm_quantization_config(is_qat=is_qat) + if is_qat: + global_config = histogram_qat_config(global_config) quantizer.set_global(global_config) configure_rife_vgf_quantizer(quantizer) if io_quantization == "int8": quantizer.set_io(global_config) elif io_quantization == "uint8": - quantizer.set_io(get_uint8_io_quantization_config(is_qat=is_qat)) + io_config: QuantizationConfig = get_uint8_io_quantization_config(is_qat=is_qat) + if is_qat: + io_config = histogram_qat_config(io_config) + quantizer.set_io(io_config) elif io_quantization == "none": quantizer.set_io(None) else: @@ -782,6 +818,13 @@ def build_qat_model( cast(Any, prepared_model).to(memory_format=torch.channels_last) prepared_model = move_exported_model_to_train(prepared_model) pruner.apply_(prepared_model) + # Shared observers must collect a common range before weight updates. + prepared_model.apply(disable_fake_quant) + with torch.no_grad(): + for inputs, _ in training_samples: + prepared_model(*inputs) + prepared_model.apply(disable_observer) + prepared_model.apply(enable_fake_quant) optimizer = torch.optim.SGD(prepared_model.parameters(), lr=lr, momentum=0.9) for step in range(steps): From c2f7511df231e6c949c6089d4a3c39fd1f346f8b Mon Sep 17 00:00:00 2001 From: Scott Roy <161522778+metascroy@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:37:50 -0800 Subject: [PATCH 063/128] [CoreAI] Add a coreai_compute_unit backend option (#24375) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `coreai_compute_unit` backend option for the Core AI delegate, set the same way as `coreai_cache_dir`. It selects which compute units Core AI may use when it specializes a model on the device. Stacked on #24374. ## Option values Each value is exactly one of the configurations the Core AI SDK provides through `SpecializationOptions`: | Value | Core AI `SpecializationOptions` | Allowed compute units | |---|---|---| | `default` (or absent) | `.default` | CPU, GPU, Neural Engine | | `cpu_only` | `.cpuOnly` | CPU | | `prefer_cpu` | `init(preferredComputeUnitKind: .cpu)` | CPU, GPU, Neural Engine | | `prefer_gpu` | `init(preferredComputeUnitKind: .gpu)` | CPU, GPU, Neural Engine | | `prefer_neural_engine` | `init(preferredComputeUnitKind: .neuralEngine)` | CPU, GPU, Neural Engine | The option mirrors the SDK and adds nothing of its own: * **`cpu_only` is the only restriction.** A preference maximizes use of that unit but falls back to any allowed unit for operations it can't run. * **No other combinations.** The SDK can't express ones like "CPU and Neural Engine only", because its allowed units can't be set. * **No separate allowed/preferred settings.** One string covers every configuration the SDK can build. ```cpp BackendOptions<2> options; ET_CHECK_OK_OR_RETURN_ERROR(options.set_option("coreai_compute_unit", "cpu_only")); LoadBackendOptionsMap map; ET_CHECK_OK_OR_RETURN_ERROR(map.set_options("CoreAIBackend", options.view())); ET_CHECK_OK_OR_RETURN_ERROR(module.load(map)); ``` ## Implementation * `coreai_backend.mm` reads the option during `init`, next to `coreai_cache_dir`. Unknown values, non-string values and null fail with `InvalidArgument` before a loader is created. The error lists the valid values. * `ETCoreAIBridge.h` adds an `ETCoreAIComputeUnit` enum, and `ETCoreAICreateModelLoader` now takes it. `ETCoreAIModelLoader.swift` maps each value to the matching `SpecializationOptions`. * **Caching:** the cache key already includes the loader's specialization identity, so each value gets its own bookmark, source directory and lock stripe. `default` keeps its existing identity, so bookmarks from before this change still restore. `clear_cache_for_pte` still removes every variant of a PTE. * Creating a loader only to evict a bookmark uses `default`, because eviction doesn't depend on specialization. * The README documents the option. ## Test plan * `coreai_host_test` passes. A new delegate test checks that each value reaches the loader factory, that a missing option means `default`, and that unknown and non-string values are rejected before a loader is created. * `coreai_swift_bridge_test` passes. A new Swift test checks that each value builds exactly the expected `SpecializationOptions`, that the five values have five different cache identities, and that `default` keeps the `"default"` identity. * Manual multithreaded check, using a standalone runner that loads a two-delegate fp16 PTE with the five values assigned round-robin across threads: * Cold, 10 threads × 50 inferences: all outputs matched the reference, with one cache key per compute unit. * Warm, 50 threads × 200: all 10,000 outputs matched, with no new specializations; every load restored its own compute unit's bookmark. --- backends/apple/coreai/CMakeLists.txt | 7 +- backends/apple/coreai/README.md | 165 ++++++- .../coreai/compiler/enumerated_shapes.py | 26 +- backends/apple/coreai/compiler/io_compat.py | 11 +- backends/apple/coreai/compiler/preprocess.py | 322 ++++++++++--- .../apple/coreai/partition/partitioner.py | 8 + .../apple/coreai/runtime/ETCoreAIBridge.h | 15 +- .../coreai/runtime/ETCoreAIModelLoader.swift | 30 ++ .../apple/coreai/runtime/coreai_backend.mm | 169 ++++++- .../apple/coreai/runtime/coreai_bridge.mm | 8 +- .../apple/coreai/runtime/coreai_cache_key.mm | 2 +- .../coreai/runtime/coreai_fault_injection.h | 2 + .../coreai/runtime/coreai_load_coordinator.h | 22 + .../coreai/runtime/coreai_load_coordinator.mm | 138 +++++- .../apple/coreai/runtime/coreai_manifest.h | 26 +- .../apple/coreai/runtime/coreai_manifest.mm | 127 ++++- .../apple/coreai/runtime/coreai_tensor_io.h | 10 +- .../apple/coreai/runtime/coreai_tensor_io.mm | 13 +- .../runtime/test/SwiftBridgeTests.swift | 59 +++ .../runtime/test/coreai_delegate_fixture.h | 10 + .../runtime/test/coreai_delegate_test.mm | 443 +++++++++++++++++- .../coreai/runtime/test/coreai_fake_loader.h | 14 + .../coreai/runtime/test/coreai_fake_loader.mm | 35 +- .../runtime/test/coreai_manifest_fixture.h | 4 +- .../runtime/test/coreai_manifest_test.mm | 136 +++++- .../runtime/test/coreai_multithread_test.cpp | 33 +- .../test/coreai_source_integrity_test.mm | 2 +- .../runtime/test/export_stress_fixture.py | 33 +- backends/apple/coreai/test/test_io_compat.py | 3 +- .../apple/coreai/test/test_multifunction.py | 435 +++++++++++++++++ backends/apple/coreai/test/test_preprocess.py | 16 +- .../apple/coreai/test/test_shape_config.py | 101 +++- 32 files changed, 2221 insertions(+), 204 deletions(-) create mode 100644 backends/apple/coreai/test/test_multifunction.py diff --git a/backends/apple/coreai/CMakeLists.txt b/backends/apple/coreai/CMakeLists.txt index b5c1bfae8a3..f82569c963d 100644 --- a/backends/apple/coreai/CMakeLists.txt +++ b/backends/apple/coreai/CMakeLists.txt @@ -463,6 +463,7 @@ if(_coreai_run_sdk_tests set(_coreai_stress_fixture "${CMAKE_CURRENT_BINARY_DIR}/stress_fixture") add_custom_command( OUTPUT "${_coreai_stress_fixture}/model.pte" + "${_coreai_stress_fixture}/model_multifunction.pte" "${_coreai_stress_fixture}/fixture.json" COMMAND "${PYTHON_EXECUTABLE}" @@ -474,8 +475,10 @@ if(_coreai_run_sdk_tests VERBATIM ) add_custom_target( - coreai_stress_fixture DEPENDS "${_coreai_stress_fixture}/model.pte" - "${_coreai_stress_fixture}/fixture.json" + coreai_stress_fixture + DEPENDS "${_coreai_stress_fixture}/model.pte" + "${_coreai_stress_fixture}/model_multifunction.pte" + "${_coreai_stress_fixture}/fixture.json" ) add_executable( coreai_multithread_test runtime/test/coreai_multithread_test.cpp diff --git a/backends/apple/coreai/README.md b/backends/apple/coreai/README.md index b4912248fa8..3b1dbfef0ed 100644 --- a/backends/apple/coreai/README.md +++ b/backends/apple/coreai/README.md @@ -26,14 +26,18 @@ python -m pytest backends/apple/coreai ## Runtime -The delegate manifest describes `.aimodel` source bundles, which require -`inline` packaging, and AOT-compiled `.aimodelc` bundles, which require -`aot_compiled_inline`. Both formats require a `function` name, and ordered +The delegate manifest's `packaging` field names the embedded format: `aimodel` +for a portable `.aimodel` bundle, located by `path`, or `aimodelc` for +AOT-compiled per-architecture `.aimodelc` bundles, located by `archs`. Both +formats require exactly one of a `function` name or `enumerated_functions` (see +[Enumerated input shapes](#enumerated-input-shapes)), and ordered `input_names` and `output_names` matching the converter's bindings. Dictionary iteration order is not a binding contract. `export_hash` identifies the exported program used to namespace embedded assets. The `files` object maps relative filenames to byte sizes; `bundle_digests` maps each bundle basename to its -export-time SHA-256 digest. Asset paths, including bundle and directory names, +export-time SHA-256 digest. The optional boolean `multifunction` marks a delegate +exported as one function of a model shared with other delegates. Asset paths, +including bundle and directory names, must remain distinct under case-insensitive, canonically normalized Unicode comparison. Accepted names are preserved, not rewritten. @@ -107,6 +111,88 @@ bookmark restoration. A missing bookmark or SDK-confirmed cache miss falls back to source materialization and persistent specialization, then binds the requested function and ordered I/O on that same acquired model. +### Multi-function export + +By default each Core AI delegate is its own model with a single `main` function. +`CoreAIPartitioner(multifunction=True)` instead lowers every Core AI partition of +every method into one model, with one function per partition named +`_`: + +```python +lowered = to_edge_transform_and_lower( + {"forward": forward_ep, "prefill": prefill_ep}, + transform_passes=get_default_passes(), + partitioner=[CoreAIPartitioner(multifunction=True)], + compile_config=get_default_compile_config(), +) +``` + +Here the delegates bind `forward_0` and `prefill_0`; a graph break adds +`forward_1`, `prefill_1` and so on. Weights shared between methods are stored +once. Every partition must carry identical compile specs (AOT config, deployment +floor), except `input_enumerations`, which each method may set on its own (see +below). + +Each delegate keeps its own manifest naming its function and ordered I/O; all of +them reference the same embedded bundle. Function bindings are not part of the +cache key, so the first load specializes the model and the other delegates +restore it from the same bookmark. Each delegate still loads its own model +instance unless the `coreai_share_multifunction_model` runtime option is set +(see [Runtime options](#runtime-options)). Multi-function manifests carry +`"multifunction": true`. + +### Enumerated input shapes + +`CoreAIPartitioner(input_enumerations=...)` compiles a dynamic dim to a fixed set +of sizes instead of leaving it dynamic. Entries align with the ET model inputs and +map a dim index to its allowed values; the dim must be exported dynamic: + +```python +batch = torch.export.Dim("batch", min=1, max=64) +ep = torch.export.export(model, (x,), dynamic_shapes={"x": {0: batch}}) +lowered = to_edge_transform_and_lower( + ep, + transform_passes=get_default_passes(), + partitioner=[CoreAIPartitioner(input_enumerations=[{0: [4, 16, 32]}])], + compile_config=get_default_compile_config(), +) +``` + +Core AI replaces the delegate's dynamic function with one static function per +combination of enumerated values, so the manifest lists them under +`enumerated_functions` instead of naming one `function`: + +```json +"enumerated_functions": [ + {"function": "main_s31_4", "input_shapes": [[4, 8]]}, + {"function": "main_s31_16", "input_shapes": [[16, 8]]}, + {"function": "main_s31_32", "input_shapes": [[32, 8]]} +] +``` + +`input_shapes` gives every input's shape in `input_names` order. The delegate +binds every listed function at load and runs the one whose shapes equal the call's +inputs; inputs matching none of them fail with `InvalidArgument` without running. +All functions come from one model, so they share weights, one specialization and +one cache entry. The function count is the product of the enumerated value counts +across dims, and specialization and load time grow with it: on an M4 Max, a warm +load of a two-layer MLP (256 to 1024 to 256 features) took 7 ms with 1 function, 41 ms with 16 and 248 ms +with 64. + +With `multifunction=True`, give each method its own partitioner to enumerate only +some methods, or different values per method: + +```python +partitioner={ + "decode": [CoreAIPartitioner(multifunction=True)], + "prefill": [CoreAIPartitioner(multifunction=True, input_enumerations=[{0: [64, 128]}])], +} +``` + +Every enumerated partition expands into its own functions (`prefill_0_`, and +`prefill_1_` past a graph break) inside the one shared model, while `decode_0` +stays a single function. The other compile specs must still match across methods. + ### Cache directory Backend initialization reads the string runtime spec `coreai_cache_dir`. If @@ -128,9 +214,9 @@ even though the new complete contents are already visible. Use a local APFS volume; unsupported full-device flushes are reported as errors, not downgraded to `fsync`. -Inline bundles are read through `NamedDataMap` and materialized as unchanged -files under the cache directory. Named data can be supplied externally; inline -packaging does not require all bytes to reside in one physical PTE. The exporter +Embedded bundles are read through `NamedDataMap` and materialized as unchanged +files under the cache directory. Named data can be supplied externally; embedded +bundles do not require all bytes to reside in one physical PTE. computes each bundle digest from all delivered relative filenames and file bytes, not just the SDK's bytecode-only `main.hash`. Each AOT architecture has an independent digest; only the selected architecture is materialized. NDS key names @@ -191,7 +277,7 @@ choose the SDK artifact directory. The backend does not inspect SDK-private files, alter their backup flags, or use an App Group cache. Each selected bundle has a `bundle_identity` derived from its bundle digest, -name, and source/AOT mode. This identifies the delivered bundle independently of +name, and packaging (`aimodel` or `aimodelc`). This identifies the delivered bundle independently of `export_hash`, which namespaces embedded assets. A readable cache key combines that bundle identity with the platform and Core AI device architecture, plus a hash of a versioned @@ -338,6 +424,69 @@ Use separate option maps to choose different cache directories for different mod The path names the cache directory, not a bundle. ExecuTorch option strings have a 255-byte limit. +`coreai_compute_unit` selects the Core AI specialization options. Each value is +one configuration the SDK provides: + +| Value | Core AI `SpecializationOptions` | Allowed compute units | +|---|---|---| +| `default` (or absent) | `.default` | CPU, GPU, Neural Engine | +| `cpu_only` | `.cpuOnly` | CPU | +| `prefer_cpu` | `init(preferredComputeUnitKind: .cpu)` | CPU, GPU, Neural Engine | +| `prefer_gpu` | `init(preferredComputeUnitKind: .gpu)` | CPU, GPU, Neural Engine | +| `prefer_neural_engine` | `init(preferredComputeUnitKind: .neuralEngine)` | CPU, GPU, Neural Engine | + +`cpu_only` is the only restriction: a preference maximizes use of that unit but +falls back to any allowed unit for operations it cannot run. The SDK has no +other combinations, such as CPU and Neural Engine only. Unknown values fail +initialization with `InvalidArgument`. The selection is part of the cache key, +so each value is specialized and cached separately; clearing a PTE removes every +variant. + +```cpp +BackendOptions<2> options; +ET_CHECK_OK_OR_RETURN_ERROR(options.set_option("coreai_compute_unit", "cpu_only")); +``` + +`coreai_expect_frequent_reshapes` (bool, default `false`) sets the SDK's +`SpecializationOptions.expectFrequentReshapes` on top of the selected compute +unit. Set it for models whose input shapes change between calls. It is part of +the cache key, so each value is specialized and cached separately, and delegates +only share a model (`coreai_share_multifunction_model`) when they agree on it. +For AOT exports, the matching export-time setting is +`AOTCompileConfig(expect_frequent_reshapes=True)`. + +```cpp +BackendOptions<2> options; +ET_CHECK_OK_OR_RETURN_ERROR( + options.set_option("coreai_expect_frequent_reshapes", true)); +``` + +`coreai_share_multifunction_model` (bool, default `false`) makes the delegates of +a multi-function export (`CoreAIPartitioner(multifunction=True)`) share one +loaded Core AI model per process instead of each loading its own. Without it, +every delegate restores a separate model with its own copy of the weights. With +it, the first delegate loads the model and the others reuse it until the last +one is destroyed. Delegates that start loading the same model concurrently wait +for that single load; loads of other models proceed in parallel. Delegates sharing a model run one at a time: concurrent +execution on one model is serialized by a per-model lock, because the SDK does +not document it as safe and it produced wrong outputs in testing. Setting the +option on a delegate that isn't from a multi-function export fails +initialization with `InvalidArgument`. + +Sharing is process-wide, not per program, but scoped to the cache directory. +Separate `Module`s that load the same PTE with the option set and the same +`coreai_cache_dir` share one model, so their `execute` calls block each other. +`Module`s with different cache directories never share: each loads its own model +and keeps its own bookmark. A symlink to a cache directory counts as that +directory. Different `coreai_compute_unit` values also load separate models, +since each is a separate specialization. + +```cpp +BackendOptions<2> options; +ET_CHECK_OK_OR_RETURN_ERROR( + options.set_option("coreai_share_multifunction_model", true)); +``` + ## SDK Bridge The private Swift module `CoreAIBridge` separates error/completion support, diff --git a/backends/apple/coreai/compiler/enumerated_shapes.py b/backends/apple/coreai/compiler/enumerated_shapes.py index e4ccad15d28..b014d1504ac 100644 --- a/backends/apple/coreai/compiler/enumerated_shapes.py +++ b/backends/apple/coreai/compiler/enumerated_shapes.py @@ -166,7 +166,15 @@ def _eval_dim(dim, subs: Dict[str, int], uname: str, axis: int) -> int: f"substitution; enumerate those dims too, or leave this input " f"fully dynamic" ) - return int(resolved) + value = int(resolved) + if value <= 0: + values = ", ".join(sorted(f"{s}={v}" for s, v in mapping.items())) + raise ValueError( + f"input '{uname}' dim {axis} is {expr}, which is {value} when " + f"{values}; Core AI needs every dim to be positive, so drop that " + "enumerated value" + ) + return value def apply_enumerated_shapes( @@ -175,7 +183,8 @@ def apply_enumerated_shapes( enumerations: Dict[str, Sequence[int]], *, input_names: Sequence[str], -) -> None: + entrypoint: str = MAIN_ENTRYPOINT, +) -> Dict[str, Dict[str, tuple]]: """Attach enumerated shapes to the coreai program (all delivery modes). ``enumerations`` is ``{symbol_name: [value, ...]}`` (from @@ -184,15 +193,19 @@ def apply_enumerated_shapes( combination of the enumerated symbol values, then attach the resulting shapes via ``set_static_shape_config``. ``input_names`` are the names the converter was given for the user inputs, in boundary order. + + Returns ``{key: {input_name: shape}}`` for the attached specializations, or + an empty dict when this subgraph has nothing enumerated. ``entrypoint`` is + erased and replaced by one function named ``f"{entrypoint}_{key}"`` per key. """ if not enumerations: - return + return {} placeholders = { n.name: n for n in edge_program.graph.nodes if n.op == "placeholder" } user_inputs = list(edge_program.graph_signature.user_inputs) - coreai_names = set(graph_input_names(program)) + coreai_names = set(graph_input_names(program, entrypoint)) # Every entry has to name every boundary input, since # set_static_shape_config treats a key as a whole-graph shape: an input left @@ -217,7 +230,7 @@ def apply_enumerated_shapes( # Nothing here is enumerated, so there is nothing to specialize. Routine # under multi-partition lowering, and legitimate when the enumerated # input is not delegated at all. - return + return {} # One axis per enumerated symbol, shared across every input that uses it. # _eval_dim rejects any boundary dim left symbolic, so a specialization @@ -243,4 +256,5 @@ def apply_enumerated_shapes( len(shapes_config), active, ) - program.set_static_shape_config(MAIN_ENTRYPOINT, shapes_config) + program.set_static_shape_config(entrypoint, shapes_config) + return shapes_config diff --git a/backends/apple/coreai/compiler/io_compat.py b/backends/apple/coreai/compiler/io_compat.py index 3968f044368..4f3ea578fc0 100644 --- a/backends/apple/coreai/compiler/io_compat.py +++ b/backends/apple/coreai/compiler/io_compat.py @@ -185,13 +185,15 @@ def io_mismatches( return errors -def coreai_io_names(program) -> Tuple[List[str], List[str]]: +def coreai_io_names( + program, entrypoint: str = MAIN_ENTRYPOINT +) -> Tuple[List[str], List[str]]: """Boundary I/O names in graph argument/result order, never dictionary order. State arguments (marked ``MutableBuffers.buffer_mutation``) and the results they name are skipped: they are not ExecuTorch boundary I/O. """ - graph = program._get_graph(MAIN_ENTRYPOINT) + graph = program._get_graph(entrypoint) arg_attrs = list(graph.arg_attrs) res_attrs = list(graph.res_attrs) for kind, attrs, types in ( @@ -227,9 +229,10 @@ def assert_io_compatible( *, input_names: Optional[Sequence[str]] = None, output_names: Optional[Sequence[str]] = None, + entrypoint: str = MAIN_ENTRYPOINT, ) -> None: """Raise if the ``.aimodel`` boundary I/O is incompatible with ExecuTorch.""" - coreai_in, coreai_out = _coreai_io(program) + coreai_in, coreai_out = _coreai_io(program, entrypoint) edge_in, edge_out = _edge_io(edge_program) logger.info( "Core AI delegate boundary I/O:\n" @@ -246,7 +249,7 @@ def assert_io_compatible( coreai_out, edge_out, "output" ) if input_names is not None or output_names is not None: - actual_inputs, actual_outputs = coreai_io_names(program) + actual_inputs, actual_outputs = coreai_io_names(program, entrypoint) for kind, actual, expected in ( ("input", actual_inputs, input_names), ("output", actual_outputs, output_names), diff --git a/backends/apple/coreai/compiler/preprocess.py b/backends/apple/coreai/compiler/preprocess.py index 0bc064f2fbb..dd67cb8e6a9 100644 --- a/backends/apple/coreai/compiler/preprocess.py +++ b/backends/apple/coreai/compiler/preprocess.py @@ -75,12 +75,17 @@ class COMPILE_SPEC_KEYS(Enum): # the portable .aimodel, so it is not part of this AOT-only blob.) AOT_COMPILE_CONFIG = "coreai_aot_compile_config" + # Lower every Core AI partition of every method into one Core AI model, one + # function per partition named _. Value: b"1". + # Every partition must carry identical compile specs. + MULTIFUNCTION = "coreai_multifunction" + class AssetPackaging(str, Enum): # Portable .aimodel embedded in the .pte via NamedDataStore. - INLINE = "inline" + AIMODEL = "aimodel" # AOT-compiled per-arch .aimodelc bundles embedded in the .pte. - AOT_COMPILED_INLINE = "aot_compiled_inline" + AIMODELC = "aimodelc" @dataclass(frozen=True) @@ -195,32 +200,44 @@ def _deliver( staging: Path, packaging: AssetPackaging, manifest_extra: Dict[str, Any], -) -> PreprocessResult: - """Embed a staging directory of bundles in the .pte with its manifest. + bindings: List[Dict[str, Any]], +) -> List[PreprocessResult]: + """Embed a staging directory of bundles in the .pte, one manifest per binding. ``staging`` holds the ``.aimodel`` / ``.aimodelc`` bundles, never their contents, so assets land under ``coreai///`` in the NamedDataStore. ``manifest_extra`` paths are staging-relative bundle names. + Each binding (``function``, ``input_names``, ``output_names``) gets its own + manifest; all of them reference the same embedded bundles. """ metadata = _asset_metadata(staging) # The SDK program hash excludes metadata such as creation timestamps. export_hash = hashlib.sha256( json.dumps(metadata["bundle_digests"], sort_keys=True).encode("utf-8") ).hexdigest() - manifest = { - "packaging": packaging.value, - "export_hash": export_hash, - **manifest_extra, - **metadata, - } - if packaging == AssetPackaging.INLINE: + manifest = {**manifest_extra, **metadata} + if packaging == AssetPackaging.AIMODEL: manifest["path"] = f"{export_hash}/{manifest['path']}" else: manifest["archs"] = { arch: f"{export_hash}/{bundle}" for arch, bundle in manifest["archs"].items() } - return _embed_dir_inline(staging, export_hash, manifest) + store_output = _embed_dir(staging, export_hash) + return [ + PreprocessResult( + processed_bytes=json.dumps( + { + "packaging": packaging.value, + "export_hash": export_hash, + **binding, + **manifest, + } + ).encode("utf-8"), + data_store_output=store_output, + ) + for binding in bindings + ] def _prepare_program_for_conversion(edge_program: ExportedProgram) -> ExportedProgram: @@ -245,35 +262,145 @@ def _prepare_program_for_conversion(edge_program: ExportedProgram) -> ExportedPr def _convert_to_aiprogram( - edge_program: ExportedProgram, - *, - input_names: Optional[List[str]] = None, - output_names: Optional[List[str]] = None, + entries: List[Tuple[ExportedProgram, str, List[str], List[str]]], ): + """Convert ``(edge_program, entrypoint, input_names, output_names)`` entries + into one Core AI program with one function per entry.""" from coreai_torch import TorchConverter - aten_program = _prepare_program_for_conversion(edge_program) converter = TorchConverter() - converter.add_exported_program( - aten_program, + for edge_program, entrypoint, input_names, output_names in entries: + converter.add_exported_program( + _prepare_program_for_conversion(edge_program), + input_names=input_names, + output_names=output_names, + entrypoint_name=entrypoint, + ) + return converter.to_coreai() + + +def _boundary_names(edge_program: ExportedProgram) -> Tuple[List[str], List[str]]: + mutated_inputs = edge_program.graph_signature.user_inputs_to_mutate + if mutated_inputs: + raise ValueError( + "Core AI delegates cannot mutate ExecuTorch-owned user inputs: " + f"{sorted(mutated_inputs.values())}" + ) + edge_inputs, edge_outputs = _edge_io(edge_program) + return ( + [f"input_{i}" for i in range(len(edge_inputs))], + [f"output_{i}" for i in range(len(edge_outputs))], + ) + + +def _binding( + program, + edge_program: ExportedProgram, + entrypoint: str, + input_names: List[str], + output_names: List[str], +) -> Dict[str, Any]: + # Fail fast if the .aimodel boundary I/O won't match what ET feeds/reads. + assert_io_compatible( + program, + edge_program, input_names=input_names, output_names=output_names, - entrypoint_name=MAIN_ENTRYPOINT, + entrypoint=entrypoint, + ) + bound_inputs, bound_outputs = coreai_io_names(program, entrypoint) + return { + "function": entrypoint, + "input_names": bound_inputs, + "output_names": bound_outputs, + } + + +def _enumerate_binding( + program, + edge_program: ExportedProgram, + binding: Dict[str, Any], + input_names: List[str], + compile_specs: List[CompileSpec], +) -> Dict[str, Any]: + """Apply this partition's enumerated shapes and rewrite its binding. + + ``set_static_shape_config`` erases the bound function, so a specialized + delegate lists every per-shape function with its input shapes, in + ``input_names`` order, and the runtime picks one by the inputs it gets. + """ + raw = _get_compile_spec(compile_specs, COMPILE_SPEC_KEYS.INPUT_ENUMERATIONS) + if not raw: + return binding + entrypoint = binding["function"] + shapes_config = apply_enumerated_shapes( + program, + edge_program, + json.loads(raw.decode()), + input_names=input_names, + entrypoint=entrypoint, + ) + if not shapes_config: + return binding + bound_inputs = binding["input_names"] + functions = [] + for key, shapes in shapes_config.items(): + if set(shapes) != set(bound_inputs): + raise ValueError( + f"enumerated shapes for {entrypoint!r} cover inputs " + f"{sorted(shapes)} but the delegate binds {bound_inputs}" + ) + functions.append( + { + "function": f"{entrypoint}_{key}", + "input_shapes": [list(shapes[name]) for name in bound_inputs], + } + ) + enumerated = {k: v for k, v in binding.items() if k != "function"} + enumerated["enumerated_functions"] = functions + return enumerated + + +def _check_unique_functions(bindings: List[Dict[str, Any]]) -> None: + """Reject two delegates binding the same function name. + + Enumerated functions are named ``__``, which a method + whose name looks like another method's index and key could reproduce. + """ + seen = set() + for binding in bindings: + names = ( + [binding["function"]] + if "function" in binding + else [f["function"] for f in binding["enumerated_functions"]] + ) + for name in names: + if name in seen: + raise ValueError( + f"multifunction export generates the function name {name!r} " + "twice; rename the method whose name collides with it" + ) + seen.add(name) + + +def _spec_items( + compile_specs: List[CompileSpec], ignore: Tuple[str, ...] = () +) -> List[Tuple[str, bytes]]: + return sorted( + (spec.key, bytes(spec.value)) + for spec in compile_specs + if spec.key not in ignore ) - return converter.to_coreai() # Asset embedding helpers. -def _embed_dir_inline( - root_dir: Path, export_hash: str, manifest_extra: Dict[str, Any] -) -> PreprocessResult: - """Flatten every file under ``root_dir`` into the NamedDataStore. +def _embed_dir(root_dir: Path, export_hash: str): + """Flatten every file under ``root_dir`` into a NamedDataStore output. ``root_dir`` is the directory *containing* the ``.aimodel`` / ``.aimodelc`` bundles, never a bundle itself, so the bundle name survives into the keys. - Keys are ``coreai/{export_hash}/{relpath}``; ``manifest_extra`` becomes the - manifest (packaging, files, archs, ...). + Keys are ``coreai/{export_hash}/{relpath}``. """ store = NamedDataStore() for path in sorted(root_dir.rglob("*")): @@ -283,10 +410,7 @@ def _embed_dir_inline( path.read_bytes(), alignment=_ASSET_ALIGNMENT, ) - return PreprocessResult( - processed_bytes=json.dumps(manifest_extra).encode("utf-8"), - data_store_output=store.get_named_data_store_output(), - ) + return store.get_named_data_store_output() def _os_version_text(min_os) -> Optional[str]: @@ -458,52 +582,110 @@ def preprocess( edge_program: ExportedProgram, compile_specs: List[CompileSpec], ) -> PreprocessResult: - aot_compiled = ( - _get_compile_spec(compile_specs, COMPILE_SPEC_KEYS.AOT_COMPILE_CONFIG) - is not None + input_names, output_names = _boundary_names(edge_program) + program = _convert_to_aiprogram( + [(edge_program, MAIN_ENTRYPOINT, input_names, output_names)] ) + # Enumerated shapes replace ``main`` with renamed clones, so bind first. + binding = _binding( + program, edge_program, MAIN_ENTRYPOINT, input_names, output_names + ) + binding = _enumerate_binding( + program, edge_program, binding, input_names, compile_specs + ) + return CoreAIBackend._package(program, compile_specs, [binding])[0] - mutated_inputs = edge_program.graph_signature.user_inputs_to_mutate - if mutated_inputs: + @classmethod + def preprocess_multimethod( + cls, + edge_programs: Dict[str, List[ExportedProgram]], + compile_specs: Dict[str, List[List[CompileSpec]]], + ) -> Dict[str, List[PreprocessResult]]: + partitions = [ + (method, index, program, compile_specs[method][index]) + for method, programs in edge_programs.items() + for index, program in enumerate(programs) + ] + if not any( + _get_compile_spec(specs, COMPILE_SPEC_KEYS.MULTIFUNCTION) is not None + for _, _, _, specs in partitions + ): + return super().preprocess_multimethod(edge_programs, compile_specs) + + missing = sorted( + { + method + for method, _, _, specs in partitions + if _get_compile_spec(specs, COMPILE_SPEC_KEYS.MULTIFUNCTION) is None + } + ) + if missing: raise ValueError( - "Core AI delegates cannot mutate ExecuTorch-owned user inputs: " - f"{sorted(mutated_inputs.values())}" + "multifunction=True must be set on the CoreAIPartitioner of every " + f"method lowered to Core AI; methods {missing} were lowered " + "without it" ) - edge_inputs, edge_outputs = _edge_io(edge_program) - input_names = [f"input_{i}" for i in range(len(edge_inputs))] - output_names = [f"output_{i}" for i in range(len(edge_outputs))] - program = _convert_to_aiprogram( - edge_program, input_names=input_names, output_names=output_names - ) - # Fail fast if the .aimodel boundary I/O won't match what ET feeds/reads. - assert_io_compatible( - program, edge_program, input_names=input_names, output_names=output_names + # Each partition applies its own enumerations, so methods may enumerate + # different values; every other spec shapes the shared model. + ignore = (COMPILE_SPEC_KEYS.INPUT_ENUMERATIONS.value,) + reference = _spec_items(partitions[0][3], ignore) + differing = sorted( + { + method + for method, _, _, specs in partitions + if _spec_items(specs, ignore) != reference + } ) - # Enumerated shapes replace ``main`` with renamed clones, so read first. - bound_inputs, bound_outputs = coreai_io_names(program) - raw_enum = _get_compile_spec( - compile_specs, COMPILE_SPEC_KEYS.INPUT_ENUMERATIONS + if differing: + raise ValueError( + "multifunction requires identical Core AI compile specs in every " + f"partition; methods {differing} differ from {partitions[0][0]!r}" + ) + entries = [ + (program, f"{method}_{index}", *_boundary_names(program)) + for method, index, program, _ in partitions + ] + logger.info( + "Lowering %d Core AI partitions into one model: %s", + len(entries), + [entrypoint for _, entrypoint, _, _ in entries], ) - if raw_enum: - apply_enumerated_shapes( - program, - edge_program, - json.loads(raw_enum.decode()), - input_names=input_names, + program = _convert_to_aiprogram(entries) + # Bind every partition before specializing any, since specializing a + # partition erases its function. + bindings = [_binding(program, *entry) for entry in entries] + bindings = [ + { + **_enumerate_binding( + program, edge_program, binding, input_names, specs + ), + "multifunction": True, + } + for binding, (edge_program, _, input_names, _), (_, _, _, specs) in zip( + bindings, entries, partitions ) - bindings = { - "function": MAIN_ENTRYPOINT, - "input_names": bound_inputs, - "output_names": bound_outputs, + ] + _check_unique_functions(bindings) + results = CoreAIBackend._package(program, partitions[0][3], bindings) + + by_method: Dict[str, List[PreprocessResult]] = { + method: [] for method in edge_programs } + for (method, _, _, _), result in zip(partitions, results): + by_method[method].append(result) + return by_method + @staticmethod + def _package( + program, compile_specs: List[CompileSpec], bindings: List[Dict[str, Any]] + ) -> List[PreprocessResult]: # min-deployment-version is a single knob applied to whichever artifact # ships: for aot-compiled delivery the .aimodelc (via coreai-build # --min-deployment-version), for portable delivery the .aimodel's floor # (via save_asset(minimum_os=...)). In the aot-compiled path the temp # .aimodel is discarded, so it keeps the default floor (no # double-specification). - if aot_compiled: + if _get_compile_spec(compile_specs, COMPILE_SPEC_KEYS.AOT_COMPILE_CONFIG): return CoreAIBackend._preprocess_aot_compiled( program, _aot_compile_options(compile_specs), bindings ) @@ -514,33 +696,32 @@ def preprocess( # Portable .aimodel delivery. @staticmethod def _preprocess_portable( - program, min_os, bindings: Dict[str, Any] - ) -> PreprocessResult: + program, min_os, bindings: List[Dict[str, Any]] + ) -> List[PreprocessResult]: with TemporaryDirectory() as tmp: _save_asset(program, Path(tmp) / "model.aimodel", min_os) return _deliver( Path(tmp), - AssetPackaging.INLINE, + AssetPackaging.AIMODEL, { - **bindings, # relative path the runtime resolves against its base "path": "model.aimodel", "min_deployment_version": _os_version_text(min_os), }, + bindings, ) # AOT-compiled .aimodelc delivery (per architecture). @staticmethod def _preprocess_aot_compiled( - program, opts: Dict[str, Any], bindings: Dict[str, Any] - ) -> PreprocessResult: + program, opts: Dict[str, Any], bindings: List[Dict[str, Any]] + ) -> List[PreprocessResult]: with TemporaryDirectory() as tmp: out = _compile_aot(program, opts, Path(tmp)) return _deliver( out, - AssetPackaging.AOT_COMPILED_INLINE, + AssetPackaging.AIMODELC, { - **bindings, "platform": opts["platform"], "min_deployment_version": opts["min_deployment_version"], "archs": { @@ -548,4 +729,5 @@ def _preprocess_aot_compiled( for arch, bundle in _compiled_arch_bundles(out) }, }, + bindings, ) diff --git a/backends/apple/coreai/partition/partitioner.py b/backends/apple/coreai/partition/partitioner.py index 4b4b482bd80..e04a73a76aa 100644 --- a/backends/apple/coreai/partition/partitioner.py +++ b/backends/apple/coreai/partition/partitioner.py @@ -317,6 +317,7 @@ def __init__( ] = None, take_over_constant_data: bool = True, take_over_mutable_buffer: bool = True, + multifunction: bool = False, ) -> None: # aot_compile_config requests ahead-of-time ``xcrun coreai-build # compile`` in preprocess, emitting per-architecture ``.aimodelc`` @@ -329,6 +330,9 @@ def __init__( # ({dim_index: [values]}, aligned to inputs); it is resolved to export # symbols in :meth:`partition` and propagated to each subgraph boundary # in preprocess, so it is not built into a compile spec here. + # + # multifunction lowers every Core AI partition of every method into one + # Core AI model, one function per partition named _. specs = [] if min_deployment_version is not None: specs.append( @@ -344,6 +348,8 @@ def __init__( aot_compile_config.to_json().encode(), ) ) + if multifunction: + specs.append(CompileSpec(COMPILE_SPEC_KEYS.MULTIFUNCTION.value, b"1")) self.delegation_spec = DelegationSpec( backend_id=CoreAIBackend.__name__, @@ -365,6 +371,8 @@ def partition(self, exported_program: ExportedProgram) -> PartitionResult: # Core AI derives op support and ops_to_not_decompose from coreai-torch's # tables; the deprecated to_edge() + to_backend() flow decomposes ops # (e.g. fused SDPA) before the partitioner runs, breaking that contract. + # It also lowers each partition on its own, so multifunction exports + # would silently become separate models without shared weights. if not self._is_to_edge_transform_and_lower(): raise RuntimeError( "CoreAIPartitioner must be used with to_edge_transform_and_lower(). " diff --git a/backends/apple/coreai/runtime/ETCoreAIBridge.h b/backends/apple/coreai/runtime/ETCoreAIBridge.h index a8fb3d2cda5..ad66ecdc236 100644 --- a/backends/apple/coreai/runtime/ETCoreAIBridge.h +++ b/backends/apple/coreai/runtime/ETCoreAIBridge.h @@ -21,6 +21,17 @@ typedef NS_ENUM(NSInteger, ETCoreAIScalarType) { ETCoreAIScalarTypeBool = 3, }; +// One value per Core AI SpecializationOptions configuration: the default (all +// compute units), cpuOnly, or a preferred compute unit kind. Preferences still +// allow every unit for operations the preferred unit cannot run. +typedef NS_ENUM(NSInteger, ETCoreAIComputeUnit) { + ETCoreAIComputeUnitDefault = 0, + ETCoreAIComputeUnitCPUOnly = 1, + ETCoreAIComputeUnitPreferCPU = 2, + ETCoreAIComputeUnitPreferGPU = 3, + ETCoreAIComputeUnitPreferNeuralEngine = 4, +}; + typedef NS_ENUM(NSInteger, ETCoreAIErrorCode) { // Invalid model descriptors, functions or bindings. ETCoreAIErrorInvalidModel = 1, @@ -136,7 +147,9 @@ extern "C" { #endif BOOL ETCoreAIIsAvailable(void); NSString* _Nullable ETCoreAIDeviceArchitectureName(void); -id ETCoreAICreateModelLoader(void); +id ETCoreAICreateModelLoader( + ETCoreAIComputeUnit computeUnit, + BOOL expectFrequentReshapes); #ifdef __cplusplus } #endif diff --git a/backends/apple/coreai/runtime/ETCoreAIModelLoader.swift b/backends/apple/coreai/runtime/ETCoreAIModelLoader.swift index adf80fbc91f..69251f231f8 100644 --- a/backends/apple/coreai/runtime/ETCoreAIModelLoader.swift +++ b/backends/apple/coreai/runtime/ETCoreAIModelLoader.swift @@ -238,6 +238,36 @@ public final class ETCoreAISwiftModelLoader: NSObject, ETCoreAIModelLoading { super.init() } + @objc public convenience init?( + computeUnit: ETCoreAIComputeUnit, expectFrequentReshapes: Bool + ) { + guard + let options = Self.specializationOptions( + for: computeUnit, expectFrequentReshapes: expectFrequentReshapes), + let specialization = try? CoreAISpecialization(options: options) + else { + return nil + } + self.init(specialization: specialization) + } + + static func specializationOptions( + for computeUnit: ETCoreAIComputeUnit, expectFrequentReshapes: Bool = false + ) -> SpecializationOptions? { + var options: SpecializationOptions + switch computeUnit { + case .default: options = .default + case .cpuOnly: options = .cpuOnly + case .preferCPU: options = SpecializationOptions(preferredComputeUnitKind: .cpu) + case .preferGPU: options = SpecializationOptions(preferredComputeUnitKind: .gpu) + case .preferNeuralEngine: + options = SpecializationOptions(preferredComputeUnitKind: .neuralEngine) + @unknown default: return nil + } + options.expectFrequentReshapes = expectFrequentReshapes + return options + } + public var specializationIdentifier: String { specialization.identifier } @objc public static var deviceArchitectureName: String { AIModel.deviceArchitectureName } diff --git a/backends/apple/coreai/runtime/coreai_backend.mm b/backends/apple/coreai/runtime/coreai_backend.mm index 7b52edbd767..069a08bc1ad 100644 --- a/backends/apple/coreai/runtime/coreai_backend.mm +++ b/backends/apple/coreai/runtime/coreai_backend.mm @@ -14,41 +14,88 @@ #import "coreai_tensor_io.h" #include +#include +#include #include #include +#include +#include namespace executorch::backends::coreai { namespace { using namespace executorch::runtime; +struct BoundFunction final { + std::vector> input_shapes; + id session = nil; +}; + struct Handle final { size_t input_count = 0; size_t output_count = 0; - id session = nil; + ETCoreAILoadedModel* model = nil; + // Enumerated delegates pick a function per call by input shape; otherwise + // the single function accepts any shapes. + bool enumerated = false; + std::vector functions; }; +template +Result +runtime_option(BackendInitContext& context, const char* key, T fallback) { + auto option = context.get_runtime_spec(key); + if (!option.ok() && option.error() == Error::NotFound) { + return fallback; + } + return option; +} + Result cache_directory_option(BackendInitContext& context) { - auto option = context.get_runtime_spec("coreai_cache_dir"); + auto option = + runtime_option(context, "coreai_cache_dir", nullptr); if (!option.ok()) { - if (option.error() == Error::NotFound) { - return cache_directory_path(nullptr); - } return option.error(); } - ET_CHECK_OR_RETURN_ERROR( - option.get() != nullptr, - InvalidArgument, - "Core AI cache directory option must not be null"); return cache_directory_path(option.get()); } +Result compute_unit_option(BackendInitContext& context) { + auto option = + runtime_option(context, "coreai_compute_unit", "default"); + if (!option.ok()) { + return option.error(); + } + static constexpr struct { + const char* name; + ETCoreAIComputeUnit value; + } kComputeUnits[] = { + {"default", ETCoreAIComputeUnitDefault}, + {"cpu_only", ETCoreAIComputeUnitCPUOnly}, + {"prefer_cpu", ETCoreAIComputeUnitPreferCPU}, + {"prefer_gpu", ETCoreAIComputeUnitPreferGPU}, + {"prefer_neural_engine", ETCoreAIComputeUnitPreferNeuralEngine}, + }; + for (const auto& unit : kComputeUnits) { + if (std::strcmp(option.get(), unit.name) == 0) { + return unit.value; + } + } + ET_LOG( + Error, + "Unknown Core AI compute unit '%s'; expected default, cpu_only, " + "prefer_cpu, prefer_gpu or prefer_neural_engine", + option.get()); + return Error::InvalidArgument; +} + Result> bind_session( id model, - const SelectedBundle& bundle) { + const SelectedBundle& bundle, + NSString* function) { dispatch_semaphore_t ready = dispatch_semaphore_create(0); __block id session = nil; __block NSError* error = nil; - [model loadFunctionNamed:bundle.function + [model loadFunctionNamed:function inputNames:bundle.inputs outputNames:bundle.outputs completion:^(id result, NSError* failure) { @@ -70,6 +117,55 @@ return bound; } +bool inputs_match( + const std::vector>& shapes, + Span args) { + for (size_t index = 0; index < shapes.size(); ++index) { + const auto sizes = args[index]->toTensor().sizes(); + if (sizes.size() != shapes[index].size() || + !std::equal(sizes.begin(), sizes.end(), shapes[index].begin())) { + return false; + } + } + return true; +} + +std::string describe_shape( + executorch::aten::ArrayRef sizes) { + std::string text = "["; + for (size_t dim = 0; dim < sizes.size(); ++dim) { + text += (dim == 0 ? "" : ", ") + std::to_string(sizes[dim]); + } + return text + "]"; +} + +// Each enumerated function accepts only its own static shapes. Called after +// argument validation, so every input is a tensor. +Result> select_session( + const Handle& handle, + Span args) { + if (!handle.enumerated) { + return handle.functions.front().session; + } + for (const auto& function : handle.functions) { + if (inputs_match(function.input_shapes, args)) { + return function.session; + } + } + std::string shapes; + for (size_t index = 0; index < handle.input_count; ++index) { + shapes += (index == 0 ? "" : ", ") + + describe_shape(args[index]->toTensor().sizes()); + } + ET_LOG( + Error, + "Core AI inputs with shapes %s match none of the %zu enumerated shapes " + "this delegate was exported with", + shapes.c_str(), + handle.functions.size()); + return Error::InvalidArgument; +} + class CoreAIBackend final : public BackendInterface { public: bool is_available() const override { @@ -97,33 +193,59 @@ bool is_available() const override { if (!configured.ok()) { return configured.error(); } - id loader = ETCoreAICreateModelLoader(); + auto compute_unit = compute_unit_option(context); + if (!compute_unit.ok()) { + return compute_unit.error(); + } + auto share = runtime_option( + context, "coreai_share_multifunction_model", false); + if (!share.ok()) { + return share.error(); + } + ET_CHECK_OR_RETURN_ERROR( + !share.get() || manifest->multifunction, + InvalidArgument, + "coreai_share_multifunction_model requires a multi-function export " + "(CoreAIPartitioner(multifunction=True))"); + auto reshapes = runtime_option( + context, "coreai_expect_frequent_reshapes", false); + if (!reshapes.ok()) { + return reshapes.error(); + } + id loader = + ETCoreAICreateModelLoader(compute_unit.get(), reshapes.get()); ET_CHECK_OR_RETURN_ERROR( loader != nil, Internal, "Cannot create Core AI loader"); auto cache_directory = create_cache_directory(configured.get()); if (!cache_directory.ok()) { return cache_directory.error(); } - auto prepared = acquire_model( + auto loaded = acquire_loaded_model( host_bundle.get(), context.get_named_data_map(), cache_directory.get(), - loader); - if (!prepared.ok()) { - return prepared.error(); - } - auto session = bind_session(prepared.get(), host_bundle->bundle); - if (!session.ok()) { - return session.error(); + loader, + share.get()); + if (!loaded.ok()) { + return loaded.error(); } auto handle = std::unique_ptr(new (std::nothrow) Handle()); ET_CHECK_OR_RETURN_ERROR( handle != nullptr, MemoryAllocationFailed, "Cannot allocate Core AI handle"); + for (const auto& function : host_bundle->bundle.functions) { + auto session = bind_session( + loaded.get().model, host_bundle->bundle, function.name); + if (!session.ok()) { + return session.error(); + } + handle->functions.push_back({function.input_shapes, session.get()}); + } handle->input_count = host_bundle->bundle.inputs.count; handle->output_count = host_bundle->bundle.outputs.count; - handle->session = session.get(); + handle->model = loaded.get(); + handle->enumerated = host_bundle->bundle.enumerated; processed->Free(); return static_cast(handle.release()); } @@ -137,8 +259,11 @@ Error execute( ET_CHECK_OR_RETURN_ERROR( opaque != nullptr, DelegateInvalidHandle, "Null Core AI handle"); auto& handle = *static_cast(opaque); + std::lock_guard guard([handle.model executionMutex]); return execute_session( - handle.session, args, handle.input_count, handle.output_count); + args, handle.input_count, handle.output_count, [&] { + return select_session(handle, args); + }); } } diff --git a/backends/apple/coreai/runtime/coreai_bridge.mm b/backends/apple/coreai/runtime/coreai_bridge.mm index 539bdb408cb..557d1247398 100644 --- a/backends/apple/coreai/runtime/coreai_bridge.mm +++ b/backends/apple/coreai/runtime/coreai_bridge.mm @@ -24,6 +24,10 @@ BOOL ETCoreAIIsAvailable(void) { return nil; } -id ETCoreAICreateModelLoader(void) { - return [[ETCoreAISwiftModelLoader alloc] init]; +id ETCoreAICreateModelLoader( + ETCoreAIComputeUnit computeUnit, + BOOL expectFrequentReshapes) { + return [[ETCoreAISwiftModelLoader alloc] + initWithComputeUnit:computeUnit + expectFrequentReshapes:expectFrequentReshapes]; } diff --git a/backends/apple/coreai/runtime/coreai_cache_key.mm b/backends/apple/coreai/runtime/coreai_cache_key.mm index 8f63ce7fd25..784fd803f5b 100644 --- a/backends/apple/coreai/runtime/coreai_cache_key.mm +++ b/backends/apple/coreai/runtime/coreai_cache_key.mm @@ -80,7 +80,7 @@ bool valid_device(NSString* device) { bool aot_compiled) { // Bumping the leading version makes every existing cache entry unreachable. auto encoded = encode_json( - @[ @"1", digest, aot_compiled ? @"aot" : @"source", bundle_name ]); + @[ @"1", digest, aot_compiled ? @"aimodelc" : @"aimodel", bundle_name ]); if (!encoded.ok()) { return encoded.error(); } diff --git a/backends/apple/coreai/runtime/coreai_fault_injection.h b/backends/apple/coreai/runtime/coreai_fault_injection.h index 28c8cd0fbbb..9091ead3541 100644 --- a/backends/apple/coreai/runtime/coreai_fault_injection.h +++ b/backends/apple/coreai/runtime/coreai_fault_injection.h @@ -29,6 +29,8 @@ enum class FaultPoint { AfterSDK, BeforeEvict, AfterEvict, + // Observation only: the result is ignored. + BeforeSharedLoadWait, }; #if defined(COREAI_FAULT_INJECTION) && COREAI_FAULT_INJECTION diff --git a/backends/apple/coreai/runtime/coreai_load_coordinator.h b/backends/apple/coreai/runtime/coreai_load_coordinator.h index 0cff9087e7d..1290287f738 100644 --- a/backends/apple/coreai/runtime/coreai_load_coordinator.h +++ b/backends/apple/coreai/runtime/coreai_load_coordinator.h @@ -12,6 +12,17 @@ #import "coreai_cache_root.h" #import "coreai_manifest.h" +#include + +// A loaded model and the lock that serializes execution on it; delegates that +// share the model share this object. The SDK does not document concurrent +// execution on one model as safe, and it produced wrong outputs in testing. +@interface ETCoreAILoadedModel : NSObject +@property(nonatomic, readonly) id model; +- (instancetype)init NS_UNAVAILABLE; +- (std::mutex&)executionMutex; +@end + namespace executorch::backends::coreai { struct SelectedHostBundle final { @@ -31,6 +42,17 @@ runtime::Result> acquire_model( const CacheDirectory& cache_directory, id loader); +// With share, returns the model another delegate in this process already holds +// for the same cache directory and cache key, otherwise acquires one and +// records it. Distinct cache directories never share, so each keeps its own +// bookmark. Entries last only while some caller retains the result. +runtime::Result acquire_loaded_model( + const SelectedHostBundle& host_bundle, + const runtime::NamedDataMap* named_data, + const CacheDirectory& cache_directory, + id loader, + bool share); + // Callers must keep affected loads and sessions quiescent across processes. runtime::Error clear_cache_entry( const CacheDirectory& cache_directory, diff --git a/backends/apple/coreai/runtime/coreai_load_coordinator.mm b/backends/apple/coreai/runtime/coreai_load_coordinator.mm index bd4f45a7de1..47932c0c9ce 100644 --- a/backends/apple/coreai/runtime/coreai_load_coordinator.mm +++ b/backends/apple/coreai/runtime/coreai_load_coordinator.mm @@ -14,8 +14,31 @@ #import "coreai_source_bundle.h" #include +#include +#include +#include #include +@interface ETCoreAILoadedModel () +- (instancetype)initWithModel:(id)model; +@end + +@implementation ETCoreAILoadedModel { + std::mutex _executionMutex; +} + +- (instancetype)initWithModel:(id)model { + if ((self = [super init])) { + _model = model; + } + return self; +} + +- (std::mutex&)executionMutex { + return _executionMutex; +} +@end + namespace executorch::backends::coreai { namespace { using runtime::Error; @@ -145,23 +168,26 @@ Error evict_one(id loader, NSData* bookmark) { std::move(selected.get()), [platform copy], [architecture copy]}; } -Result> acquire_model( +namespace { +Result model_cache_key( const SelectedHostBundle& host_bundle, - const runtime::NamedDataMap* named_data, - const CacheDirectory& cache_directory, id loader) { ET_CHECK_OR_RETURN_ERROR( loader != nil, InvalidArgument, "Missing Core AI model loader"); NSString* specialization = [loader.specializationIdentifier copy]; - auto key = cache_variant_key( + return cache_variant_key( host_bundle.bundle, host_bundle.platform, host_bundle.architecture, specialization); - if (!key.ok()) { - return key.error(); - } - NSString* cache_key = key.get(); +} + +Result> acquire_cached_model( + const SelectedHostBundle& host_bundle, + const runtime::NamedDataMap* named_data, + const CacheDirectory& cache_directory, + id loader, + NSString* cache_key) { ET_LOG(Info, "Core AI cache lookup: key=%s", cache_key.UTF8String); auto locked = LockedCacheEntry::acquire(cache_directory, cache_key); if (!locked.ok()) { @@ -206,7 +232,7 @@ Error evict_one(id loader, NSData* bookmark) { ET_LOG( Info, "Core AI specialization started: kind=%s, key=%s", - host_bundle.bundle.aot_compiled ? "aot" : "source", + host_bundle.bundle.aot_compiled ? "aimodelc" : "aimodel", cache_key.UTF8String); [loader specializeModelAtURL:url completion:completion]; }); @@ -226,6 +252,96 @@ Error evict_one(id loader, NSData* bookmark) { cleanup_source(*locked.get()); return model; } +} // namespace + +Result> acquire_model( + const SelectedHostBundle& host_bundle, + const runtime::NamedDataMap* named_data, + const CacheDirectory& cache_directory, + id loader) { + auto key = model_cache_key(host_bundle, loader); + if (!key.ok()) { + return key.error(); + } + return acquire_cached_model( + host_bundle, named_data, cache_directory, loader, key.get()); +} + +Result acquire_loaded_model( + const SelectedHostBundle& host_bundle, + const runtime::NamedDataMap* named_data, + const CacheDirectory& cache_directory, + id loader, + bool share) { + auto key = model_cache_key(host_bundle, loader); + if (!key.ok()) { + return key.error(); + } + static std::mutex mutex; + static std::condition_variable load_finished; + static NSMapTable* shared_models = + [NSMapTable strongToWeakObjectsMapTable]; + static NSMutableSet* loading = [NSMutableSet set]; + NSString* shared_key = nil; + if (share) { + // The open directory's identity, not its path, so aliases of one + // directory share while distinct directories stay isolated. + struct stat directory; + ET_CHECK_OR_RETURN_ERROR( + fstat(cache_directory.descriptor.get(), &directory) == 0, + AccessFailed, + "Cannot identify Core AI cache directory"); + shared_key = [NSString + stringWithFormat:@"%llu:%llu/%@", + static_cast(directory.st_dev), + static_cast(directory.st_ino), + key.get()]; + // Concurrent first loads of one key wait for a single acquisition, which + // may specialize for a long time, while other keys load in parallel. A + // failed acquisition leaves no entry, so a waiter then acquires itself. + std::unique_lock guard(mutex); + while (true) { + ETCoreAILoadedModel* existing = [shared_models objectForKey:shared_key]; + if (existing != nil) { + ET_LOG( + Info, "Core AI shared model reused: key=%s", key.get().UTF8String); + return existing; + } + if (![loading containsObject:shared_key]) { + break; + } + static_cast(fault_point(FaultPoint::BeforeSharedLoadWait)); + load_finished.wait(guard); + } + [loading addObject:shared_key]; + } + // Runs on every exit, so a key cannot stay marked as loading and hang later + // loads of the same model. + struct FinishSharedLoad final { + NSString* key; + ETCoreAILoadedModel* loaded = nil; + ~FinishSharedLoad() { + if (key == nil) { + return; + } + { + std::lock_guard guard(mutex); + [loading removeObject:key]; + if (loaded != nil) { + [shared_models setObject:loaded forKey:key]; + } + } + load_finished.notify_all(); + } + } finish{shared_key}; + auto model = acquire_cached_model( + host_bundle, named_data, cache_directory, loader, key.get()); + if (!model.ok()) { + return model.error(); + } + finish.loaded = [[ETCoreAILoadedModel alloc] initWithModel:model.get()]; + return finish.loaded; +} Error clear_cache_entry( const CacheDirectory& cache_directory, @@ -255,7 +371,9 @@ Error clear_cache_entry( } else { if (loader == nil) { ET_CHECK_OK_OR_RETURN_ERROR(require_coreai_available()); - loader = ETCoreAICreateModelLoader(); + // Eviction targets the bookmark, so the loader's specialization is + // irrelevant. + loader = ETCoreAICreateModelLoader(ETCoreAIComputeUnitDefault, NO); ET_CHECK_OR_RETURN_ERROR( loader != nil, Internal, "Cannot create Core AI model loader"); } diff --git a/backends/apple/coreai/runtime/coreai_manifest.h b/backends/apple/coreai/runtime/coreai_manifest.h index 9627761c0ce..635fa2dc9b0 100644 --- a/backends/apple/coreai/runtime/coreai_manifest.h +++ b/backends/apple/coreai/runtime/coreai_manifest.h @@ -11,12 +11,25 @@ #import #include +#include +#include + namespace executorch::backends::coreai { +// A function the delegate can bind. One without input shapes accepts any input +// shapes; enumerated-shape exports list one per shape, and each accepts exactly +// its input_shapes, in input-name order. +struct ManifestFunction { + NSString* name; + std::vector> input_shapes; +}; + class Manifest final { public: NSString* const export_hash; - NSString* const function; + const std::vector functions; + // Listed under enumerated_functions; each function has static input shapes. + const bool enumerated; NSArray* const inputs; NSArray* const outputs; NSDictionary* const files; @@ -26,6 +39,8 @@ class Manifest final { NSString* const platform; NSDictionary* const architectures; const NSOperatingSystemVersion minimum_version; + // Exported with other delegates as functions of one Core AI model. + const bool multifunction; Manifest(const Manifest&) = default; Manifest(Manifest&&) = default; @@ -35,7 +50,8 @@ class Manifest final { private: Manifest( NSString* export_hash, - NSString* function, + std::vector functions, + bool enumerated, NSArray* inputs, NSArray* outputs, NSDictionary* files, @@ -43,7 +59,8 @@ class Manifest final { bool aot_compiled, NSString* platform, NSDictionary* architectures, - NSOperatingSystemVersion minimum_version); + NSOperatingSystemVersion minimum_version, + bool multifunction); friend runtime::Result parse_manifest(NSData* data); }; @@ -54,7 +71,8 @@ class SelectedBundle final { NSString* const path; NSString* const bundle_name; NSString* const digest; - NSString* const function; + const std::vector functions; + const bool enumerated; NSArray* const inputs; NSArray* const outputs; NSDictionary* const files; diff --git a/backends/apple/coreai/runtime/coreai_manifest.mm b/backends/apple/coreai/runtime/coreai_manifest.mm index af139c8f09d..87e17046d9c 100644 --- a/backends/apple/coreai/runtime/coreai_manifest.mm +++ b/backends/apple/coreai/runtime/coreai_manifest.mm @@ -9,13 +9,16 @@ #import "coreai_manifest.h" #include #include +#include +#include #import "coreai_validation.h" namespace executorch::backends::coreai { Manifest::Manifest( NSString* export_hash, - NSString* function, + std::vector functions, + bool enumerated, NSArray* inputs, NSArray* outputs, NSDictionary* files, @@ -23,9 +26,11 @@ bool aot_compiled, NSString* platform, NSDictionary* architectures, - NSOperatingSystemVersion minimum_version) + NSOperatingSystemVersion minimum_version, + bool multifunction) : export_hash(export_hash), - function(function), + functions(std::move(functions)), + enumerated(enumerated), inputs(inputs), outputs(outputs), files(files), @@ -33,7 +38,8 @@ aot_compiled(aot_compiled), platform(platform), architectures(architectures), - minimum_version(minimum_version) {} + minimum_version(minimum_version), + multifunction(multifunction) {} SelectedBundle::SelectedBundle( const Manifest& manifest, @@ -43,7 +49,8 @@ path(path), bundle_name(path.lastPathComponent), digest(manifest.bundle_digests[path.lastPathComponent]), - function(manifest.function), + functions(manifest.functions), + enumerated(manifest.enumerated), inputs(manifest.inputs), outputs(manifest.outputs), files([files copy]), @@ -67,9 +74,74 @@ bool names_array(id value) { return true; } +bool is_boolean(id value) { + return value != nil && + CFGetTypeID((__bridge CFTypeRef)value) == CFBooleanGetTypeID(); +} + +bool is_dimension(id value) { + return [value isKindOfClass:NSNumber.class] && !is_boolean(value) && + !CFNumberIsFloatType((__bridge CFNumberRef)value) && + [value longLongValue] > 0 && + [value longLongValue] <= std::numeric_limits::max(); +} + +// Each entry names a distinct function and gives every input's shape; no two +// entries may accept the same shapes, so dispatch is unambiguous. +Error parse_enumerated_functions( + id value, + NSUInteger input_count, + std::vector& functions) { + ET_CHECK_OR_RETURN_ERROR( + [value isKindOfClass:NSArray.class] && [value count] > 0, + InvalidProgram, + "Core AI enumerated functions must be a nonempty array"); + NSMutableSet* names = [NSMutableSet set]; + std::set>> seen_shapes; + for (id entry in value) { + ET_CHECK_OR_RETURN_ERROR( + [entry isKindOfClass:NSDictionary.class], + InvalidProgram, + "Core AI enumerated function must be an object"); + id name = entry[@"function"]; + ET_CHECK_OR_RETURN_ERROR( + is_nonempty_string(name) && ![names containsObject:name], + InvalidProgram, + "Core AI enumerated functions need distinct nonempty names"); + [names addObject:name]; + id shapes = entry[@"input_shapes"]; + ET_CHECK_OR_RETURN_ERROR( + [shapes isKindOfClass:NSArray.class] && [shapes count] == input_count, + InvalidProgram, + "Core AI enumerated function %s needs one shape per input", + [name UTF8String]); + std::vector> input_shapes; + for (id shape in shapes) { + ET_CHECK_OR_RETURN_ERROR( + [shape isKindOfClass:NSArray.class], + InvalidProgram, + "Core AI enumerated shape must be an array"); + std::vector dims; + for (id dim in shape) { + ET_CHECK_OR_RETURN_ERROR( + is_dimension(dim), + InvalidProgram, + "Core AI enumerated shape dims must be positive integers"); + dims.push_back([dim longLongValue]); + } + input_shapes.push_back(std::move(dims)); + } + ET_CHECK_OR_RETURN_ERROR( + seen_shapes.insert(input_shapes).second, + InvalidProgram, + "Core AI enumerated functions accept the same input shapes"); + functions.push_back({name, std::move(input_shapes)}); + } + return Error::Ok; +} + bool file_size(id value) { - return [value isKindOfClass:NSNumber.class] && - CFGetTypeID((__bridge CFTypeRef)value) != CFBooleanGetTypeID() && + return [value isKindOfClass:NSNumber.class] && !is_boolean(value) && !CFNumberIsFloatType((__bridge CFNumberRef)value) && [value compare:@0] != NSOrderedAscending && [value compare:@(std::numeric_limits::max())] != @@ -174,15 +246,21 @@ bool parse_version(id value, NSOperatingSystemVersion& version) { InvalidProgram, "Missing Core AI packaging"); ET_CHECK_OR_RETURN_ERROR( - [packaging isEqualToString:@"inline"] || - [packaging isEqualToString:@"aot_compiled_inline"], + [packaging isEqualToString:@"aimodel"] || + [packaging isEqualToString:@"aimodelc"], NotSupported, - "Unsupported Core AI packaging; expected .aimodel or AOT .aimodelc"); - const bool aot_compiled = [packaging hasPrefix:@"aot_compiled_"]; + "Unsupported Core AI packaging; expected aimodel or aimodelc"); + const bool aot_compiled = [packaging isEqualToString:@"aimodelc"]; NSString* export_hash = dict[@"export_hash"]; - NSString* function = dict[@"function"]; + id function = dict[@"function"]; + id enumerated_functions = dict[@"enumerated_functions"]; NSArray* inputs = dict[@"input_names"]; NSArray* outputs = dict[@"output_names"]; + id multifunction = dict[@"multifunction"]; + ET_CHECK_OR_RETURN_ERROR( + multifunction == nil || is_boolean(multifunction), + InvalidProgram, + "Core AI multifunction flag must be a boolean"); NSString* platform = nil; NSDictionary* architectures = nil; ET_CHECK_OR_RETURN_ERROR( @@ -232,10 +310,25 @@ bool parse_version(id value, NSOperatingSystemVersion& version) { [bundles addObject:@"model.aimodel"]; } ET_CHECK_OR_RETURN_ERROR( - is_nonempty_string(function) && names_array(inputs) && - names_array(outputs), + names_array(inputs) && names_array(outputs), InvalidProgram, "Core AI manifest requires ordered input/output names"); + ET_CHECK_OR_RETURN_ERROR( + (function == nil) != (enumerated_functions == nil), + InvalidProgram, + "Core AI manifest requires exactly one of function or " + "enumerated_functions"); + std::vector functions; + if (function != nil) { + ET_CHECK_OR_RETURN_ERROR( + is_nonempty_string(function), + InvalidProgram, + "Core AI manifest function must be a nonempty string"); + functions.push_back({function, {}}); + } else { + ET_CHECK_OK_OR_RETURN_ERROR(parse_enumerated_functions( + enumerated_functions, inputs.count, functions)); + } NSOperatingSystemVersion minimum_version{}; ET_CHECK_OR_RETURN_ERROR( parse_version(dict[@"min_deployment_version"], minimum_version), @@ -258,7 +351,8 @@ bool parse_version(id value, NSOperatingSystemVersion& version) { } return Manifest( export_hash, - function, + std::move(functions), + enumerated_functions != nil, inputs, outputs, files, @@ -266,7 +360,8 @@ bool parse_version(id value, NSOperatingSystemVersion& version) { aot_compiled, platform, architectures, - minimum_version); + minimum_version, + [multifunction boolValue]); } Result parse_manifest(const void* data, size_t size) { diff --git a/backends/apple/coreai/runtime/coreai_tensor_io.h b/backends/apple/coreai/runtime/coreai_tensor_io.h index 1a49564face..f372065ddd7 100644 --- a/backends/apple/coreai/runtime/coreai_tensor_io.h +++ b/backends/apple/coreai/runtime/coreai_tensor_io.h @@ -8,15 +8,21 @@ #pragma once #include +#include #include + +#include #import "ETCoreAIBridge.h" namespace executorch::backends::coreai { +// Validates args, then runs the session select_session picks for them; the +// selector may assume every argument is a valid tensor. runtime::Error execute_session( - id session, runtime::Span args, size_t input_count, - size_t output_count); + size_t output_count, + const std::function>()>& + select_session); } // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_tensor_io.mm b/backends/apple/coreai/runtime/coreai_tensor_io.mm index 746b5dac38f..ec27d9d5252 100644 --- a/backends/apple/coreai/runtime/coreai_tensor_io.mm +++ b/backends/apple/coreai/runtime/coreai_tensor_io.mm @@ -377,16 +377,21 @@ Error copy_staged( } // namespace Error execute_session( - id session, Span args, size_t input_count, - size_t output_count) { - ET_CHECK_OR_RETURN_ERROR( - session != nil, DelegateInvalidHandle, "Missing Core AI session"); + size_t output_count, + const std::function>()>& select_session) { auto validated = validate_arguments(args, input_count, output_count); if (!validated.ok()) { return validated.error(); } + auto selected = select_session(); + if (!selected.ok()) { + return selected.error(); + } + id session = selected.get(); + ET_CHECK_OR_RETURN_ERROR( + session != nil, DelegateInvalidHandle, "Missing Core AI session"); auto inputs = borrow_inputs(args, validated.get(), input_count); if (!inputs.ok()) { return inputs.error(); diff --git a/backends/apple/coreai/runtime/test/SwiftBridgeTests.swift b/backends/apple/coreai/runtime/test/SwiftBridgeTests.swift index fbbe6274ef7..745b07f84fa 100644 --- a/backends/apple/coreai/runtime/test/SwiftBridgeTests.swift +++ b/backends/apple/coreai/runtime/test/SwiftBridgeTests.swift @@ -153,6 +153,65 @@ final class SwiftSpecializationTests: XCTestCase { loader.specializationIdentifier, try CoreAISpecialization(options: options).identifier) } + func testComputeUnitsMapToSDKSpecializationOptions() throws { + let expected: [(ETCoreAIComputeUnit, SpecializationOptions)] = [ + (.default, .default), + (.cpuOnly, .cpuOnly), + (.preferCPU, SpecializationOptions(preferredComputeUnitKind: .cpu)), + (.preferGPU, SpecializationOptions(preferredComputeUnitKind: .gpu)), + (.preferNeuralEngine, SpecializationOptions(preferredComputeUnitKind: .neuralEngine)), + ] + var variants: [(options: SpecializationOptions, identifier: String)] = [] + var report: [String] = [] + for (unit, options) in expected { + XCTAssertEqual(ETCoreAISwiftModelLoader.specializationOptions(for: unit), options) + for reshapes in [false, true] { + var expectedOptions = options + expectedOptions.expectFrequentReshapes = reshapes + XCTAssertEqual( + ETCoreAISwiftModelLoader.specializationOptions( + for: unit, expectFrequentReshapes: reshapes), + expectedOptions) + let loader = try XCTUnwrap( + ETCoreAISwiftModelLoader(computeUnit: unit, expectFrequentReshapes: reshapes)) + XCTAssertEqual( + loader.specializationIdentifier, + try CoreAISpecialization(options: expectedOptions).identifier) + variants.append((expectedOptions, loader.specializationIdentifier)) + report.append( + "\(unit.rawValue) reshapes=\(reshapes): \(expectedOptions) -> " + + loader.specializationIdentifier) + } + } + assertIdentifiersMatchExactlyWhenOptionsMatch(variants, report: report) + XCTAssertEqual( + ETCoreAISwiftModelLoader(computeUnit: .default, expectFrequentReshapes: false)? + .specializationIdentifier, + "default") + XCTAssertNil( + ETCoreAISwiftModelLoader( + computeUnit: ETCoreAIComputeUnit(rawValue: 99)!, expectFrequentReshapes: false)) + } + + // The SDK limits allowed units to the hardware (a CPU-only machine makes + // cpuOnly equal default), so identifiers must match exactly when options do. + private func assertIdentifiersMatchExactlyWhenOptionsMatch( + _ variants: [(options: SpecializationOptions, identifier: String)], + report: [String], + file: StaticString = #filePath, + line: UInt = #line + ) { + for a in variants.indices { + for b in variants.indices where a < b { + XCTAssertEqual( + variants[a].identifier == variants[b].identifier, + variants[a].options == variants[b].options, + "variants \(a) and \(b):\n" + report.joined(separator: "\n"), + file: file, line: line) + } + } + } + func testDescriptorIsVersionedAndUsesStableComputeUnitNames() throws { var options = SpecializationOptions(preferredComputeUnitKind: .gpu) options.expectFrequentReshapes = !SpecializationOptions.default.expectFrequentReshapes diff --git a/backends/apple/coreai/runtime/test/coreai_delegate_fixture.h b/backends/apple/coreai/runtime/test/coreai_delegate_fixture.h index 7d9a9b64fa1..67f937f4b98 100644 --- a/backends/apple/coreai/runtime/test/coreai_delegate_fixture.h +++ b/backends/apple/coreai/runtime/test/coreai_delegate_fixture.h @@ -31,6 +31,16 @@ class DelegateFixture final { runtime::Error init_with_options( runtime::Span options, runtime::MemoryAllocator* allocator = nullptr); + // Adds coreai_cache_dir for this fixture's storage to the other options. + template + runtime::Error init_with_storage(runtime::BackendOptions& options) { + const runtime::Error error = + options.set_option("coreai_cache_dir", storage.url.path.UTF8String); + if (error != runtime::Error::Ok) { + return error; + } + return init_with_options({options.view().data(), options.view().size()}); + } void reset(); runtime::Error execute(runtime::Span args); diff --git a/backends/apple/coreai/runtime/test/coreai_delegate_test.mm b/backends/apple/coreai/runtime/test/coreai_delegate_test.mm index c637ebd3374..c7e12bb8da7 100644 --- a/backends/apple/coreai/runtime/test/coreai_delegate_test.mm +++ b/backends/apple/coreai/runtime/test/coreai_delegate_test.mm @@ -13,12 +13,18 @@ #include #include #include +#include +#include #include #include +#include +#include +#include #include "coreai_cache.h" #include "coreai_cache_entry_fixture.h" #import "coreai_delegate_fixture.h" +#include "coreai_fault_scope.h" #include "coreai_test_utils.h" namespace executorch::backends::coreai::testing { @@ -153,7 +159,7 @@ void expect_preflight_rejection( TEST_F(CoreAIDelegateTest, PreflightRejectsFutureDeploymentForSourceAndAot) { @autoreleasepool { for (bool aot : {false, true}) { - SCOPED_TRACE(aot ? "AOT" : "source"); + SCOPED_TRACE(aot ? "aimodelc" : "aimodel"); auto dict = aot ? aot_manifest_dict() : manifest_dict(); dict[@"min_deployment_version"] = @"999999.0"; expect_preflight_rejection( @@ -243,6 +249,95 @@ void expect_preflight_rejection( } } +NSMutableDictionary* enumerated_manifest(NSArray* functions) { + NSMutableDictionary* manifest = manifest_dict(); + [manifest removeObjectForKey:@"function"]; + manifest[@"enumerated_functions"] = functions; + return manifest; +} + +// A 1-D float tensor of `size` elements over `data`, shaped once at +// construction because TensorImpl caches its element count. +struct VectorTensor final { + executorch::aten::TensorImpl::SizesType size[1]; + executorch::aten::TensorImpl::DimOrderType order[1] = {0}; + executorch::aten::TensorImpl::StridesType stride[1] = {1}; + executorch::aten::TensorImpl impl; + EValue value; + VectorTensor(float* data, int size_value, bool dynamic = false) + : size{size_value}, + impl( + executorch::aten::ScalarType::Float, + 1, + size, + data, + order, + stride, + dynamic ? runtime::TensorShapeDynamism::DYNAMIC_BOUND + : runtime::TensorShapeDynamism::STATIC), + value(executorch::aten::Tensor(&impl)) {} +}; + +TEST_F(CoreAIDelegateTest, EnumeratedFunctionsDispatchByInputShape) { + @autoreleasepool { + bridge.state().functions = + [NSSet setWithArray:@[ @"main_two", @"main_one" ]]; + DelegateFixture fixture( + backend, bridge, enumerated_manifest(@[ + @{@"function" : @"main_two", + @"input_shapes" : @[ @[ @2 ] ]}, + @{@"function" : @"main_one", + @"input_shapes" : @[ @[ @1 ] ]}, + ])); + ASSERT_EQ(fixture.init(), Error::Ok); + EXPECT_EQ(bridge.state().binding_mismatches.load(), 0); + + FloatTensors two; + ASSERT_EQ(fixture.execute({two.args, 2}), Error::Ok); + EXPECT_TRUE([bridge.state().last_function isEqual:@"main_two"]); + EXPECT_EQ(two.output[0], 3); + EXPECT_EQ(two.output[1], 7); + + float one_input[1] = {5}; + float one_output[2] = {-1, -1}; + VectorTensor one_in(one_input, 1); + VectorTensor one_out(one_output, 2, /*dynamic=*/true); + EValue* one_args[] = {&one_in.value, &one_out.value}; + ASSERT_EQ(fixture.execute({one_args, 2}), Error::Ok); + EXPECT_TRUE([bridge.state().last_function isEqual:@"main_one"]); + EXPECT_EQ(one_out.impl.size(0), 1); + EXPECT_EQ(one_output[0], 5); + + float three_input[3] = {1, 2, 3}; + float three_output[3] = {-1, -1, -1}; + VectorTensor three_in(three_input, 3); + VectorTensor three_out(three_output, 3, /*dynamic=*/true); + EValue* three_args[] = {&three_in.value, &three_out.value}; + const int executions = bridge.state().executions.load(); + EXPECT_EQ(fixture.execute({three_args, 2}), Error::InvalidArgument); + EXPECT_EQ(bridge.state().executions.load(), executions); + EXPECT_EQ(three_output[0], -1); + } +} + +TEST_F(CoreAIDelegateTest, EnumeratedFunctionBindFailureFailsInit) { + @autoreleasepool { + bridge.state().functions = + [NSSet setWithArray:@[ @"main_two", @"main_one" ]]; + bridge.state().fail_function = @"main_one"; + DelegateFixture fixture( + backend, bridge, enumerated_manifest(@[ + @{@"function" : @"main_two", + @"input_shapes" : @[ @[ @2 ] ]}, + @{@"function" : @"main_one", + @"input_shapes" : @[ @[ @1 ] ]}, + ])); + EXPECT_EQ(fixture.init(), Error::InvalidProgram); + EXPECT_EQ(bridge.state().function_bindings.load(), 2); + EXPECT_EQ(fixture.freed, 0); + } +} + TEST_F(CoreAIDelegateTest, AdjacentInputAndOutputStorageCopiesDirectly) { @autoreleasepool { DelegateFixture fixture(backend, bridge); @@ -905,25 +1000,349 @@ TensorImpl output( } } -TEST_F(CoreAIDelegateTest, StorageOptionsRejectWrongTypeAndRelativePaths) { +TEST_F(CoreAIDelegateTest, OptionsRejectWrongTypes) { + @autoreleasepool { + const BackendOption wrong_types[] = { + {"coreai_cache_dir", 1}, + {"coreai_compute_unit", 1}, + {"coreai_share_multifunction_model", 1}, + {"coreai_expect_frequent_reshapes", 1}, + }; + for (const auto& option : wrong_types) { + SCOPED_TRACE(option.key); + DelegateFixture fixture(backend, bridge); + EXPECT_EQ( + fixture.init_with_options({&option, 1}), Error::InvalidArgument); + EXPECT_EQ(fixture.freed, 0); + } + } +} + +TEST_F(CoreAIDelegateTest, StorageOptionsRejectRelativePaths) { @autoreleasepool { DelegateFixture fixture(backend, bridge); + BackendOptions<1> relative; + ASSERT_EQ(relative.set_option("coreai_cache_dir", "relative"), Error::Ok); + EXPECT_EQ( + fixture.init_with_options( + {relative.view().data(), relative.view().size()}), + Error::InvalidArgument); + EXPECT_EQ(fixture.freed, 0); + } +} + +TEST_F(CoreAIDelegateTest, ComputeUnitOptionSelectsSpecialization) { + @autoreleasepool { + // A successful init frees the processed manifest, so each case gets a + // fresh fixture. + auto init_with_compute_unit = [&](const char* name) { + bridge.state().last_compute_unit = -1; + DelegateFixture fixture(backend, bridge); + BackendOptions<2> options; + if (name != nullptr) { + EXPECT_EQ(options.set_option("coreai_compute_unit", name), Error::Ok); + } + return fixture.init_with_storage(options); + }; { - SCOPED_TRACE("integer cache directory"); - BackendOption wrong_type{"coreai_cache_dir", 1}; + SCOPED_TRACE("absent"); + ASSERT_EQ(init_with_compute_unit(nullptr), Error::Ok); EXPECT_EQ( - fixture.init_with_options({&wrong_type, 1}), Error::InvalidArgument); + bridge.state().last_compute_unit.load(), ETCoreAIComputeUnitDefault); } - { - SCOPED_TRACE("relative cache directory"); - BackendOptions<1> relative; - ASSERT_EQ(relative.set_option("coreai_cache_dir", "relative"), Error::Ok); + const std::pair valid[] = { + {"default", ETCoreAIComputeUnitDefault}, + {"cpu_only", ETCoreAIComputeUnitCPUOnly}, + {"prefer_cpu", ETCoreAIComputeUnitPreferCPU}, + {"prefer_gpu", ETCoreAIComputeUnitPreferGPU}, + {"prefer_neural_engine", ETCoreAIComputeUnitPreferNeuralEngine}, + }; + for (const auto& [name, unit] : valid) { + SCOPED_TRACE(name); + ASSERT_EQ(init_with_compute_unit(name), Error::Ok); + EXPECT_EQ(bridge.state().last_compute_unit.load(), unit); + } + for (const char* name : {"gpu", "neural_engine", "CPU_ONLY", ""}) { + SCOPED_TRACE(name); + EXPECT_EQ(init_with_compute_unit(name), Error::InvalidArgument); + EXPECT_EQ(bridge.state().last_compute_unit.load(), -1); + } + } +} + +TEST_F(CoreAIDelegateTest, ExpectFrequentReshapesOptionReachesLoader) { + @autoreleasepool { + auto init_with_reshapes = [&](std::optional reshapes) { + bridge.state().last_expect_frequent_reshapes = -1; + DelegateFixture fixture(backend, bridge); + BackendOptions<3> options; EXPECT_EQ( - fixture.init_with_options( - {relative.view().data(), relative.view().size()}), - Error::InvalidArgument); + options.set_option("coreai_compute_unit", "cpu_only"), Error::Ok); + if (reshapes.has_value()) { + EXPECT_EQ( + options.set_option("coreai_expect_frequent_reshapes", *reshapes), + Error::Ok); + } + return fixture.init_with_storage(options); + }; + for (const auto& [reshapes, expected] : + {std::pair, int>{std::nullopt, 0}, + std::pair, int>{false, 0}, + std::pair, int>{true, 1}}) { + SCOPED_TRACE( + reshapes.has_value() ? (*reshapes ? "true" : "false") : "absent"); + ASSERT_EQ(init_with_reshapes(reshapes), Error::Ok); + EXPECT_EQ(bridge.state().last_expect_frequent_reshapes.load(), expected); + EXPECT_EQ( + bridge.state().last_compute_unit.load(), ETCoreAIComputeUnitCPUOnly); } + } +} + +NSMutableDictionary* multifunction_manifest() { + NSMutableDictionary* manifest = manifest_dict(); + manifest[@"multifunction"] = @YES; + return manifest; +} + +// Uses the fixture's own cache directory unless root names another; sharing is +// scoped to the cache directory. +Error init_sharing( + DelegateFixture& fixture, + std::optional share, + NSString* root = nil) { + BackendOptions<2> options; + if (share.has_value()) { + const Error error = + options.set_option("coreai_share_multifunction_model", *share); + if (error != Error::Ok) { + return error; + } + } + if (root == nil) { + return fixture.init_with_storage(options); + } + const Error error = options.set_option("coreai_cache_dir", root.UTF8String); + if (error != Error::Ok) { + return error; + } + return fixture.init_with_options( + {options.view().data(), options.view().size()}); +} + +TEST_F(CoreAIDelegateTest, ShareOptionRequiresMultifunctionExport) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + EXPECT_EQ(init_sharing(fixture, true), Error::InvalidArgument); + EXPECT_EQ(bridge.state().prepared_models.load(), 0); EXPECT_EQ(fixture.freed, 0); + DelegateFixture allowed(backend, bridge); + EXPECT_EQ(init_sharing(allowed, false), Error::Ok); + } +} + +TEST_F(CoreAIDelegateTest, ShareOptionSharesOneModelUntilLastDelegate) { + @autoreleasepool { + { + SCOPED_TRACE("off loads one model per delegate"); + DelegateFixture first(backend, bridge, multifunction_manifest()); + DelegateFixture second(backend, bridge, multifunction_manifest()); + ASSERT_EQ(init_sharing(first, std::nullopt), Error::Ok); + ASSERT_EQ(init_sharing(second, false, first.storage.url.path), Error::Ok); + EXPECT_EQ(bridge.state().prepared_models.load(), 2); + } + EXPECT_EQ(bridge.state().prepared_models.load(), 0); + { + SCOPED_TRACE("on shares one model"); + DelegateFixture first(backend, bridge, multifunction_manifest()); + DelegateFixture second(backend, bridge, multifunction_manifest()); + ASSERT_EQ(init_sharing(first, true), Error::Ok); + ASSERT_EQ(init_sharing(second, true, first.storage.url.path), Error::Ok); + EXPECT_EQ(bridge.state().prepared_models.load(), 1); + EXPECT_EQ(bridge.state().sessions.load(), 2); + for (auto* fixture : {&first, &second}) { + FloatTensors tensors; + ASSERT_EQ(fixture->execute({tensors.args, 2}), Error::Ok); + EXPECT_EQ(tensors.output[0], 3); + EXPECT_EQ(tensors.output[1], 7); + } + first.reset(); + EXPECT_EQ(bridge.state().prepared_models.load(), 1); + second.reset(); + EXPECT_EQ(bridge.state().prepared_models.load(), 0); + } + } +} + +TEST_F(CoreAIDelegateTest, SharedModelsAreScopedToCacheDirectory) { + @autoreleasepool { + DelegateFixture first(backend, bridge, multifunction_manifest()); + DelegateFixture other_directory(backend, bridge, multifunction_manifest()); + DelegateFixture alias(backend, bridge, multifunction_manifest()); + ASSERT_EQ(init_sharing(first, true), Error::Ok); + ASSERT_EQ(init_sharing(other_directory, true), Error::Ok); + EXPECT_EQ(bridge.state().prepared_models.load(), 2); + NSURL* link = [alias.storage.url URLByAppendingPathComponent:@"alias"]; + ASSERT_TRUE([NSFileManager.defaultManager + createSymbolicLinkAtURL:link + withDestinationURL:first.storage.url + error:nil]); + ASSERT_EQ(init_sharing(alias, true, link.path), Error::Ok); + EXPECT_EQ(bridge.state().prepared_models.load(), 2); + } +} + +TEST_F(CoreAIDelegateTest, SharedModelSerializesConcurrentExecution) { + @autoreleasepool { + bridge.state().execute_delay_us = 1000; + // The unshared run shows the workload does overlap when nothing serializes + // it, so the shared assertion can fail. + for (const bool share : {false, true}) { + SCOPED_TRACE(share ? "shared" : "unshared"); + bridge.state().max_active_executions = 0; + DelegateFixture first(backend, bridge, multifunction_manifest()); + DelegateFixture second(backend, bridge, multifunction_manifest()); + ASSERT_EQ(init_sharing(first, share), Error::Ok); + ASSERT_EQ(init_sharing(second, share, first.storage.url.path), Error::Ok); + std::atomic failures{0}; + auto run = [&](DelegateFixture* fixture) { + for (int iteration = 0; iteration < 20; ++iteration) { + FloatTensors tensors; + if (fixture->execute({tensors.args, 2}) != Error::Ok || + tensors.output[0] != 3 || tensors.output[1] != 7) { + ++failures; + } + } + }; + std::thread worker(run, &first); + run(&second); + worker.join(); + EXPECT_EQ(failures.load(), 0); + if (share) { + EXPECT_EQ(bridge.state().max_active_executions.load(), 1); + } else { + EXPECT_GT(bridge.state().max_active_executions.load(), 1); + } + } + } +} + +TEST_F(CoreAIDelegateTest, ConcurrentFirstSharedLoadsAcquireOnce) { + @autoreleasepool { + BookmarkLoaderScope fake; + // Widens the window in which a second load could miss the registry. + fake.loader.onSpecialize = ^(NSURL*) { + usleep(50000); + }; + DelegateFixture first(backend, bridge, multifunction_manifest()); + DelegateFixture second(backend, bridge, multifunction_manifest()); + Error second_result = Error::Internal; + NSString* root = first.storage.url.path; + std::thread worker( + [&] { second_result = init_sharing(second, true, root); }); + const Error first_result = init_sharing(first, true); + worker.join(); + EXPECT_EQ(first_result, Error::Ok); + EXPECT_EQ(second_result, Error::Ok); + EXPECT_EQ( + fake.loader->specializations.load() + fake.loader->restores.load(), 1); + } +} + +TEST_F(CoreAIDelegateTest, SharedLoadsOfDifferentModelsRunConcurrently) { + @autoreleasepool { + BookmarkLoaderScope fake; + // Each specialization waits until both are in flight; if one shared load + // blocked the other, neither would ever see two in flight. + auto in_flight = std::make_shared>(0); + auto max_in_flight = std::make_shared>(0); + fake.loader.onSpecialize = ^(NSURL*) { + const int now = ++*in_flight; + int seen = max_in_flight->load(); + while (now > seen && !max_in_flight->compare_exchange_weak(seen, now)) { + } + for (int i = 0; i < 400 && max_in_flight->load() < 2; ++i) { + usleep(5000); + } + --*in_flight; + }; + DelegateFixture first(backend, bridge, multifunction_manifest()); + DelegateFixture second(backend, bridge, multifunction_manifest()); + Error second_result = Error::Internal; + std::thread worker([&] { second_result = init_sharing(second, true); }); + const Error first_result = init_sharing(first, true); + worker.join(); + EXPECT_EQ(first_result, Error::Ok); + EXPECT_EQ(second_result, Error::Ok); + EXPECT_EQ(fake.loader->specializations.load(), 2); + EXPECT_EQ(max_in_flight->load(), 2); + } +} + +TEST_F(CoreAIDelegateTest, WaiterAcquiresAfterConcurrentSharedLoadFails) { + @autoreleasepool { + BookmarkLoaderScope fake; + auto calls = std::make_shared>(0); + auto first_in_flight = std::make_shared>(false); + auto second_waiting = std::make_shared>(false); + __weak BookmarkFakeLoader* loader = fake.loader; + // The first acquisition fails only once the second load waits on it. + fake.loader.onSpecialize = ^(NSURL*) { + if (++*calls == 1) { + *first_in_flight = true; + for (int i = 0; i < 1000 && !*second_waiting; ++i) { + usleep(5000); + } + loader.specializeError = [NSError errorWithDomain:NSPOSIXErrorDomain + code:EIO + userInfo:nil]; + } else { + loader.specializeError = nil; + } + }; + DelegateFixture first(backend, bridge, multifunction_manifest()); + DelegateFixture second(backend, bridge, multifunction_manifest()); + NSString* root = first.storage.url.path; + Error first_result = Error::Ok; + std::thread worker([&] { first_result = init_sharing(first, true); }); + while (!*first_in_flight) { + std::this_thread::yield(); + } + Error second_result = Error::Internal; + { + StorageFaultScope observe_wait(FaultPoint::BeforeSharedLoadWait); + observe_wait.armed = false; + observe_wait.observe = ^(FaultPoint point) { + if (point == FaultPoint::BeforeSharedLoadWait) { + *second_waiting = true; + } + }; + second_result = init_sharing(second, true, root); + } + worker.join(); + EXPECT_TRUE(second_waiting->load()); + EXPECT_NE(first_result, Error::Ok); + EXPECT_EQ(second_result, Error::Ok); + EXPECT_EQ(calls->load(), 2); + } +} + +TEST_F(CoreAIDelegateTest, FailedSharedAcquisitionLeavesNoEntry) { + @autoreleasepool { + BookmarkLoaderScope fake; + fake.loader.specializeError = [NSError errorWithDomain:NSPOSIXErrorDomain + code:EIO + userInfo:nil]; + DelegateFixture failed(backend, bridge, multifunction_manifest()); + EXPECT_NE(init_sharing(failed, true), Error::Ok); + fake.loader.specializeError = nil; + DelegateFixture first(backend, bridge, multifunction_manifest()); + DelegateFixture second(backend, bridge, multifunction_manifest()); + NSString* root = failed.storage.url.path; + ASSERT_EQ(init_sharing(first, true, root), Error::Ok); + ASSERT_EQ(init_sharing(second, true, root), Error::Ok); + EXPECT_EQ(fake.loader->specializations.load(), 2); + EXPECT_EQ(fake.loader->restores.load(), 0); } } diff --git a/backends/apple/coreai/runtime/test/coreai_fake_loader.h b/backends/apple/coreai/runtime/test/coreai_fake_loader.h index 5fb287ed79a..cf6df68f1ad 100644 --- a/backends/apple/coreai/runtime/test/coreai_fake_loader.h +++ b/backends/apple/coreai/runtime/test/coreai_fake_loader.h @@ -9,6 +9,7 @@ #pragma once #include +#include #include #import "ETCoreAIBridge.h" @@ -30,17 +31,29 @@ struct FakeBridgeState { bool multiple_io = false; bool retain_binding_completion = false; bool retain_execution_completion = false; + // Function names the fake model has; nil means only "main". + NSSet* functions = nil; + // Binding this function fails like a load failure; the others still bind. + NSString* fail_function = nil; + std::atomic function_bindings{0}; + useconds_t execute_delay_us = 0; NSString* last_bundle = nil; + // Written by the execution that last completed, before its completion. + NSString* last_function = nil; dispatch_semaphore_t input_ready = nil; dispatch_semaphore_t allow_input_read = nil; std::atomic architecture_queries{0}; std::atomic executions{0}; + std::atomic active_executions{0}; + std::atomic max_active_executions{0}; std::atomic output_copies{0}; std::atomic output_owners{0}; std::atomic last_output_destination{nullptr}; std::atomic sessions{0}; std::atomic prepared_models{0}; std::atomic loaders{0}; + std::atomic last_compute_unit{-1}; + std::atomic last_expect_frequent_reshapes{-1}; std::atomic last_input_bytes{nullptr}; std::atomic last_input_byte_count{0}; std::atomic last_input_first_byte{0}; @@ -71,6 +84,7 @@ class ScopedFakeBridgeState final { @interface FakeSession : NSObject @property(nonatomic, strong) id preparedPin; +@property(nonatomic, copy) NSString* function; @end @interface FakePreparedModel : NSObject diff --git a/backends/apple/coreai/runtime/test/coreai_fake_loader.mm b/backends/apple/coreai/runtime/test/coreai_fake_loader.mm index 5eefda12f9d..609c42e8c2e 100644 --- a/backends/apple/coreai/runtime/test/coreai_fake_loader.mm +++ b/backends/apple/coreai/runtime/test/coreai_fake_loader.mm @@ -53,7 +53,9 @@ bool valid_binding( NSArray* expected_outputs = context->state.multiple_io ? @[ @"output_1", @"output_0" ] : @[ @"output_0" ]; - const bool valid = [function isEqualToString:@"main"] && + NSSet* functions = + context->state.functions ?: [NSSet setWithObject:@"main"]; + const bool valid = [functions containsObject:function] && [inputs isEqualToArray:expected_inputs] && [outputs isEqualToArray:expected_outputs]; if (!valid) { @@ -133,6 +135,20 @@ - (void)executeInputs:(NSArray*)inputs (void (^)(NSArray*, NSError*))completion { CoreAIFakeBridgeContext* context = self.context; ++context->state.executions; + const int active = ++context->state.active_executions; + int peak = context->state.max_active_executions.load(); + while (active > peak && + !context->state.max_active_executions.compare_exchange_weak( + peak, active)) { + } + // Ends the overlap window before the caller can observe completion. + void (^reply)(NSArray*, NSError*) = completion; + completion = ^(NSArray* outputs, NSError* error) { + --context->state.active_executions; + reply(outputs, error); + }; + const useconds_t delay = context->state.execute_delay_us; + NSString* function = self.function; const bool should_fail = context->state.fail_execute; const bool malformed = context->state.bad_output; dispatch_semaphore_t ready = context->state.input_ready; @@ -141,7 +157,11 @@ - (void)executeInputs:(NSArray*)inputs context.retainedExecutionCompletion = completion; } run_async(context, ^{ + if (delay > 0) { + usleep(delay); + } ETCoreAIInputTensor* input = inputs[0]; + context->state.last_function = function; context->state.last_input_bytes = input.bytes; context->state.last_input_byte_count = input.byteCount; if (ready != nil) { @@ -228,7 +248,9 @@ - (void)loadFunctionNamed:(NSString*)functionName CoreAIFakeBridgeContext* context = self.context; const bool valid = valid_binding(context, functionName, inputNames, outputNames); - const bool should_fail = context->state.fail_load; + const bool should_fail = context->state.fail_load || + [context->state.fail_function isEqualToString:functionName]; + ++context->state.function_bindings; if (context->state.retain_binding_completion) { context.retainedBindingCompletion = completion; } @@ -239,6 +261,7 @@ - (void)loadFunctionNamed:(NSString*)functionName } FakeSession* session = [[FakeSession alloc] initWithContext:context]; session.preparedPin = self; + session.function = functionName; completion(session, nil); }); } @@ -349,6 +372,7 @@ - (void)loadFunctionNamed:(NSString*)functionName } FakeSession* session = [[FakeSession alloc] initWithContext:context]; session.preparedPin = self; + session.function = functionName; completion(session, nil); }); } @@ -537,9 +561,14 @@ BOOL ETCoreAIIsAvailable(void) { return current_context->state.device_architecture; } -id ETCoreAICreateModelLoader(void) { +id ETCoreAICreateModelLoader( + ETCoreAIComputeUnit computeUnit, + BOOL expectFrequentReshapes) { if (current_context == nil || current_context->state.fail_loader_factory) { return nil; } + current_context->state.last_compute_unit = static_cast(computeUnit); + current_context->state.last_expect_frequent_reshapes = + expectFrequentReshapes ? 1 : 0; return current_context->state.bookmark_loader ?: [[FakeLoader alloc] init]; } diff --git a/backends/apple/coreai/runtime/test/coreai_manifest_fixture.h b/backends/apple/coreai/runtime/test/coreai_manifest_fixture.h index bf9af55c0bb..8cd2e99a276 100644 --- a/backends/apple/coreai/runtime/test/coreai_manifest_fixture.h +++ b/backends/apple/coreai/runtime/test/coreai_manifest_fixture.h @@ -22,7 +22,7 @@ inline NSString* fixture_identity(char digit) { inline NSMutableDictionary* manifest_dict() { return [@{ - @"packaging" : @"inline", + @"packaging" : @"aimodel", @"export_hash" : @"ab", @"path" : @"ab/model.aimodel", @"function" : @"main", @@ -37,7 +37,7 @@ inline NSMutableDictionary* manifest_dict() { inline NSMutableDictionary* aot_manifest_dict() { auto dict = manifest_dict(); [dict removeObjectForKey:@"path"]; - dict[@"packaging"] = @"aot_compiled_inline"; + dict[@"packaging"] = @"aimodelc"; dict[@"platform"] = @"macOS"; dict[@"archs"] = @{ @"arch_b" : @"ab/model.arch_b.aimodelc", diff --git a/backends/apple/coreai/runtime/test/coreai_manifest_test.mm b/backends/apple/coreai/runtime/test/coreai_manifest_test.mm index 2e0fa5393e7..cbef5562b03 100644 --- a/backends/apple/coreai/runtime/test/coreai_manifest_test.mm +++ b/backends/apple/coreai/runtime/test/coreai_manifest_test.mm @@ -6,8 +6,10 @@ * LICENSE file in the root directory of this source tree. */ +#include #include #include +#include #include "coreai_manifest_fixture.h" #include "coreai_validation.h" @@ -30,6 +32,130 @@ EXPECT_EQ(parse_manifest(encode(dict)).error(), Error::NotSupported); } +NSMutableDictionary* enumerated_manifest() { + NSMutableDictionary* dict = manifest_dict(); + [dict removeObjectForKey:@"function"]; + dict[@"enumerated_functions"] = @[ + @{@"function" : @"main_s0_4", + @"input_shapes" : @[ @[ @4, @8 ] ]}, + @{@"function" : @"main_s0_16", + @"input_shapes" : @[ @[ @16, @8 ] ]}, + ]; + return dict; +} + +TEST(CoreAIManifestTest, ParsesEnumeratedFunctions) { + auto parsed = parse_manifest(encode(enumerated_manifest())); + ASSERT_TRUE(parsed.ok()); + EXPECT_TRUE(parsed->enumerated); + ASSERT_EQ(parsed->functions.size(), 2u); + EXPECT_TRUE([parsed->functions[0].name isEqual:@"main_s0_4"]); + EXPECT_EQ( + parsed->functions[0].input_shapes, + (std::vector>{{4, 8}})); + EXPECT_TRUE([parsed->functions[1].name isEqual:@"main_s0_16"]); + EXPECT_EQ( + parsed->functions[1].input_shapes, + (std::vector>{{16, 8}})); +} + +TEST(CoreAIManifestTest, RejectsInvalidEnumeratedFunctions) { + { + SCOPED_TRACE("both function and enumerated_functions"); + auto dict = enumerated_manifest(); + dict[@"function"] = @"main"; + EXPECT_EQ(parse_manifest(encode(dict)).error(), Error::InvalidProgram); + } + { + SCOPED_TRACE("neither"); + auto dict = enumerated_manifest(); + [dict removeObjectForKey:@"enumerated_functions"]; + EXPECT_EQ(parse_manifest(encode(dict)).error(), Error::InvalidProgram); + } + NSArray* invalid = @[ + @[], + @{}, + @[ @"main_s0_4" ], + @[ + @{@"input_shapes" : @[ @[ @4, @8 ] ]} + ], + @[ + @{@"function" : @"", + @"input_shapes" : @[ @[ @4, @8 ] ]} + ], + @[ @{@"function" : @"f"} ], + @[ + @{@"function" : @"f", + @"input_shapes" : @[]} + ], + @[ + @{@"function" : @"f", + @"input_shapes" : @[ @[ @4 ], @[ @8 ] ]} + ], + @[ + @{@"function" : @"f", + @"input_shapes" : @[ @"4x8" ]} + ], + @[ + @{@"function" : @"f", + @"input_shapes" : @[ @[ @0, @8 ] ]} + ], + @[ + @{@"function" : @"f", + @"input_shapes" : @[ @[ @-4, @8 ] ]} + ], + @[ + @{@"function" : @"f", + @"input_shapes" : @[ @[ @4.5, @8 ] ]} + ], + @[ + @{@"function" : @"f", + @"input_shapes" : @[ @[ @YES, @8 ] ]} + ], + @[ + @{@"function" : @"f", + @"input_shapes" : @[ @[ @(1LL << 40), @8 ] ]} + ], + @[ + @{@"function" : @"f", + @"input_shapes" : @[ @[ @4, @8 ] ]}, + @{@"function" : @"f", + @"input_shapes" : @[ @[ @16, @8 ] ]} + ], + @[ + @{@"function" : @"f", + @"input_shapes" : @[ @[ @4, @8 ] ]}, + @{@"function" : @"g", + @"input_shapes" : @[ @[ @4, @8 ] ]} + ], + ]; + for (id functions in invalid) { + SCOPED_TRACE([functions description].UTF8String); + auto dict = enumerated_manifest(); + dict[@"enumerated_functions"] = functions; + EXPECT_EQ(parse_manifest(encode(dict)).error(), Error::InvalidProgram); + } +} + +TEST(CoreAIManifestTest, ParsesOptionalMultifunctionFlag) { + auto absent = parse_manifest(encode(manifest_dict())); + ASSERT_TRUE(absent.ok()); + EXPECT_FALSE(absent->multifunction); + for (NSNumber* value in @[ @YES, @NO ]) { + auto dict = manifest_dict(); + dict[@"multifunction"] = value; + auto parsed = parse_manifest(encode(dict)); + ASSERT_TRUE(parsed.ok()); + EXPECT_EQ(parsed->multifunction, value.boolValue); + } + for (id value in @[ @1, @"true", NSNull.null ]) { + SCOPED_TRACE([value description].UTF8String); + auto dict = manifest_dict(); + dict[@"multifunction"] = value; + EXPECT_EQ(parse_manifest(encode(dict)).error(), Error::InvalidProgram); + } +} + TEST(CoreAIManifestTest, RejectsUnsafeSourcePaths) { for (NSString* path in @[ @"../model.aimodel", @@ -171,7 +297,10 @@ ASSERT_TRUE(parsed.ok()); EXPECT_EQ(parsed->aot_compiled, GetParam()); EXPECT_TRUE([parsed->export_hash isEqual:dict[@"export_hash"]]); - EXPECT_TRUE([parsed->function isEqual:dict[@"function"]]); + ASSERT_EQ(parsed->functions.size(), 1u); + EXPECT_TRUE([parsed->functions[0].name isEqual:dict[@"function"]]); + EXPECT_TRUE(parsed->functions[0].input_shapes.empty()); + EXPECT_FALSE(parsed->enumerated); EXPECT_TRUE([parsed->inputs isEqual:dict[@"input_names"]]); EXPECT_TRUE([parsed->outputs isEqual:dict[@"output_names"]]); EXPECT_TRUE([parsed->files isEqual:dict[@"files"]]); @@ -397,7 +526,8 @@ EXPECT_EQ(selected->files.count, 2); NSString* bundle = selected->path.lastPathComponent; EXPECT_TRUE([selected->export_hash isEqual:parsed->export_hash]); - EXPECT_TRUE([selected->function isEqual:parsed->function]); + EXPECT_TRUE( + [selected->functions[0].name isEqual:parsed->functions[0].name]); EXPECT_TRUE([selected->inputs isEqual:parsed->inputs]); EXPECT_TRUE([selected->outputs isEqual:parsed->outputs]); EXPECT_EQ(selected->aot_compiled, parsed->aot_compiled); @@ -424,7 +554,7 @@ ASSERT_TRUE(selected.ok()); EXPECT_TRUE([selected->path isEqualToString:dict[@"path"]]); EXPECT_TRUE([selected->export_hash isEqual:parsed->export_hash]); - EXPECT_TRUE([selected->function isEqual:parsed->function]); + EXPECT_TRUE([selected->functions[0].name isEqual:parsed->functions[0].name]); EXPECT_TRUE([selected->inputs isEqual:parsed->inputs]); EXPECT_TRUE([selected->outputs isEqual:parsed->outputs]); EXPECT_TRUE([selected->files isEqual:parsed->files]); diff --git a/backends/apple/coreai/runtime/test/coreai_multithread_test.cpp b/backends/apple/coreai/runtime/test/coreai_multithread_test.cpp index b08f5dd5310..204087d814f 100644 --- a/backends/apple/coreai/runtime/test/coreai_multithread_test.cpp +++ b/backends/apple/coreai/runtime/test/coreai_multithread_test.cpp @@ -29,6 +29,7 @@ #include #include #include +#include #include namespace et = executorch::runtime; @@ -119,6 +120,7 @@ struct JoinWorkers { void round( const std::string& pte, const std::string& cache, + bool share, const std::vector& sample, const std::vector& expected, size_t threads, @@ -142,11 +144,14 @@ void round( return; } Input input(sample); - et::BackendOptions<1> options; + et::BackendOptions<2> options; et::LoadBackendOptionsMap option_map; check( options.set_option("coreai_cache_dir", cache.c_str()), "set option"); + check( + options.set_option("coreai_share_multifunction_model", share), + "set share option"); check( option_map.set_options("CoreAIBackend", options.view()), "set backend options"); @@ -234,15 +239,23 @@ int main(int argc, char** argv) { std::cout << threads << " threads x " << iterations << " iterations per round\n" << "Cache: " << cache << std::endl; - for (const char* phase : {"cold", "warm"}) { - std::cout << phase << " round" << std::endl; - round( - (fixture / "model.pte").string(), - cache, - sample, - expected, - threads, - iterations); + // The shared variant puts every thread's delegates on one Core AI model, + // so its executions must be serialized to stay correct. + for (const auto& [file, share] : + {std::pair{"model.pte", false}, + std::pair{"model_multifunction.pte", true}}) { + for (const char* phase : {"cold", "warm"}) { + std::cout << file << (share ? " (shared model) " : " ") << phase + << " round" << std::endl; + round( + (fixture / file).string(), + cache, + share, + sample, + expected, + threads, + iterations); + } } check( executorch::backends::coreai::clear_cache(cache.c_str()), diff --git a/backends/apple/coreai/runtime/test/coreai_source_integrity_test.mm b/backends/apple/coreai/runtime/test/coreai_source_integrity_test.mm index 264042988e7..02596987687 100644 --- a/backends/apple/coreai/runtime/test/coreai_source_integrity_test.mm +++ b/backends/apple/coreai/runtime/test/coreai_source_integrity_test.mm @@ -58,7 +58,7 @@ TEST_F(CoreAISourceTest, RejectsMalformedStoredBundlesWithoutReplacement) { for (bool aot : {false, true}) { - SCOPED_TRACE(aot ? "aot" : "source"); + SCOPED_TRACE(aot ? "aimodelc" : "aimodel"); for (NSString* damage in @[ @"missing-file", @"extra-file", diff --git a/backends/apple/coreai/runtime/test/export_stress_fixture.py b/backends/apple/coreai/runtime/test/export_stress_fixture.py index 441ac9663e8..1583223f5b1 100644 --- a/backends/apple/coreai/runtime/test/export_stress_fixture.py +++ b/backends/apple/coreai/runtime/test/export_stress_fixture.py @@ -60,19 +60,11 @@ def __call__(self, graph_module): raise RuntimeError("Missing residual boundary") -def export_fixture(output_dir): - # Distinct weights keep SDK artifacts separate from the playground model. - torch.manual_seed(20261029) - model = ResidualMLP().eval().half().requires_grad_(False) - sample = torch.randn(1, 32, dtype=torch.float16) - with torch.no_grad(): - expected = model(sample) - assert expected.dtype == torch.float16 and torch.isfinite(expected).all() - exported = torch.export.export(model, (sample,)) +def lower(exported, multifunction): lowered = to_edge_transform_and_lower( exported, transform_passes=[*get_default_passes(), TagResidualBoundary()], - partitioner=[CoreAIPartitioner()], + partitioner=[CoreAIPartitioner(multifunction=multifunction)], compile_config=get_default_compile_config(), ) graph = lowered.exported_program().graph_module @@ -88,9 +80,26 @@ def export_fixture(output_dir): and (node.op != "call_function" or node.target not in allowed) ] assert len(fallback) == 1 and fallback[0].target == exir_ops.edge.aten.relu.default + return lowered + + +def export_fixture(output_dir): + # Distinct weights keep SDK artifacts separate from the playground model. + torch.manual_seed(20261029) + model = ResidualMLP().eval().half().requires_grad_(False) + sample = torch.randn(1, 32, dtype=torch.float16) + with torch.no_grad(): + expected = model(sample) + assert expected.dtype == torch.float16 and torch.isfinite(expected).all() + exported = torch.export.export(model, (sample,)) output_dir.mkdir(parents=True, exist_ok=True) - with (output_dir / "model.pte").open("wb") as stream: - lowered.to_executorch().write_to_file(stream) + # The two partitions become two functions of one Core AI model. + for name, multifunction in ( + ("model.pte", False), + ("model_multifunction.pte", True), + ): + with (output_dir / name).open("wb") as stream: + lower(exported, multifunction).to_executorch().write_to_file(stream) (output_dir / "fixture.json").write_text( json.dumps( { diff --git a/backends/apple/coreai/test/test_io_compat.py b/backends/apple/coreai/test/test_io_compat.py index e22e43094b5..058a79712ef 100644 --- a/backends/apple/coreai/test/test_io_compat.py +++ b/backends/apple/coreai/test/test_io_compat.py @@ -22,6 +22,7 @@ get_default_compile_config, get_default_passes, ) +from executorch.backends.apple.coreai.compiler.constants import MAIN_ENTRYPOINT from executorch.backends.apple.coreai.compiler.io_compat import ( assert_io_compatible, io_mismatches, @@ -54,7 +55,7 @@ def forward(self, z, a): inputs = ["input_0", "input_1"] outputs = ["output_0", "output_1"] program = _convert_to_aiprogram( - edge, input_names=inputs[::-1], output_names=outputs + [(edge, MAIN_ENTRYPOINT, inputs[::-1], outputs)] ) with self.assertRaisesRegex(ValueError, "input names mismatch"): assert_io_compatible( diff --git a/backends/apple/coreai/test/test_multifunction.py b/backends/apple/coreai/test/test_multifunction.py new file mode 100644 index 00000000000..7dd3747c4a5 --- /dev/null +++ b/backends/apple/coreai/test/test_multifunction.py @@ -0,0 +1,435 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import json +import subprocess +import tempfile +import unittest +from unittest import mock + +import torch +import torch.nn as nn +from executorch.backends.apple.coreai import ( + AOTCompileConfig, + get_default_compile_config, + get_default_passes, +) +from executorch.backends.apple.coreai.compiler.preprocess import ( + _check_unique_functions, + COMPILE_SPEC_KEYS, +) +from executorch.backends.apple.coreai.partition.partitioner import ( + CoreAIPartitioner, + do_not_delegate, +) +from executorch.exir import to_edge_transform_and_lower +from executorch.exir.lowered_backend_module import get_lowered_backend_modules +from executorch.exir.pass_base import PassResult + +_MOCK_BUILD = "executorch.backends.apple.coreai.compiler.preprocess._run_coreai_build" +_WIDTH = 256 + + +def _coreai_build_available() -> bool: + try: + return ( + subprocess.run( + ["xcrun", "--find", "coreai-build"], capture_output=True + ).returncode + == 0 + ) + except FileNotFoundError: + return False + + +class _Stack(nn.Module): + def __init__(self): + super().__init__() + self.up = nn.Linear(_WIDTH, _WIDTH) + self.down = nn.Linear(_WIDTH, _WIDTH) + + def forward(self, x): + return self.down(torch.relu(self.up(x)) * 2.0) + + +class _BreakAtMul: + def __call__(self, gm): + for node in gm.graph.nodes: + if node.op == "call_function" and "mul" in str(node.target): + do_not_delegate(node) + return PassResult(gm, True) + + +def _inputs(): + return {"forward": (torch.randn(1, _WIDTH),), "prefill": (torch.randn(8, _WIDTH),)} + + +def _lower(partitioner, *, graph_break=False, model=None, inputs=None): + torch.manual_seed(0) + model = model or _Stack().eval() + inputs = inputs or _inputs() + passes = list(get_default_passes()) + ([_BreakAtMul()] if graph_break else []) + return to_edge_transform_and_lower( + {name: torch.export.export(model, args) for name, args in inputs.items()}, + transform_passes=passes, + partitioner=partitioner, + compile_config=get_default_compile_config(), + ) + + +def _lower_enumerated(*, graph_break=False, model=None, rows=(4, 8, 16)): + """``forward`` is static; ``prefill`` has a dynamic row count it enumerates.""" + torch.manual_seed(0) + model = model or _Stack().eval() + dim = torch.export.Dim("rows", min=1, max=64) + passes = list(get_default_passes()) + ([_BreakAtMul()] if graph_break else []) + return to_edge_transform_and_lower( + { + "forward": torch.export.export(model, (torch.randn(1, _WIDTH),)), + "prefill": torch.export.export( + model, (torch.randn(8, _WIDTH),), dynamic_shapes=({0: dim},) + ), + }, + transform_passes=passes, + partitioner={ + "forward": [CoreAIPartitioner(multifunction=True)], + "prefill": [ + CoreAIPartitioner( + multifunction=True, input_enumerations=[{0: list(rows)}] + ) + ], + }, + compile_config=get_default_compile_config(), + ) + + +def _manifests(lowered, method): + graph = lowered.exported_program(method).graph_module + return [ + (json.loads(bytes(lbm.processed_bytes)), lbm) + for lbm in get_lowered_backend_modules(graph) + ] + + +def _bundle_bytes(lbm): + return sum(len(blob) for blob in lbm.named_data_store_output.buffers) + + +class MultifunctionPartitionerTest(unittest.TestCase): + def test_option_adds_compile_spec(self): + keys = [ + spec.key + for spec in CoreAIPartitioner( + multifunction=True + ).delegation_spec.compile_specs + ] + self.assertIn(COMPILE_SPEC_KEYS.MULTIFUNCTION.value, keys) + default_keys = [ + spec.key for spec in CoreAIPartitioner().delegation_spec.compile_specs + ] + self.assertNotIn(COMPILE_SPEC_KEYS.MULTIFUNCTION.value, default_keys) + + +class MultifunctionPreprocessTest(unittest.TestCase): + def test_methods_share_one_bundle(self): + lowered = _lower([CoreAIPartitioner(multifunction=True)]) + ((forward, forward_lbm),) = _manifests(lowered, "forward") + ((prefill, prefill_lbm),) = _manifests(lowered, "prefill") + + self.assertEqual(forward["function"], "forward_0") + self.assertEqual(prefill["function"], "prefill_0") + self.assertEqual(forward["packaging"], "aimodel") + self.assertIs(forward["multifunction"], True) + self.assertIs(prefill["multifunction"], True) + for key in ("export_hash", "path", "files", "bundle_digests"): + self.assertEqual(forward[key], prefill[key], key) + self.assertEqual(forward["input_names"], ["input_0"]) + self.assertEqual(prefill["output_names"], ["output_0"]) + self.assertEqual( + set(forward_lbm.named_data_store_output.pte_data), + set(prefill_lbm.named_data_store_output.pte_data), + ) + + # Shared weights are stored once: two functions cost about one bundle. + ((single, single_lbm),) = _manifests( + _lower( + [CoreAIPartitioner()], + inputs={"forward": _inputs()["forward"]}, + ), + "forward", + ) + self.assertEqual(single["function"], "main") + self.assertLess(_bundle_bytes(forward_lbm), 1.1 * _bundle_bytes(single_lbm)) + self.assertGreater(_bundle_bytes(single_lbm), 2 * _WIDTH * _WIDTH * 4) + + def test_graph_break_partitions_become_functions(self): + lowered = _lower([CoreAIPartitioner(multifunction=True)], graph_break=True) + functions = { + method: [m["function"] for m, _ in _manifests(lowered, method)] + for method in ("forward", "prefill") + } + self.assertEqual( + functions, + { + "forward": ["forward_0", "forward_1"], + "prefill": ["prefill_0", "prefill_1"], + }, + ) + hashes = { + m["export_hash"] + for method in ("forward", "prefill") + for m, _ in _manifests(lowered, method) + } + self.assertEqual(len(hashes), 1) + + def test_disabled_keeps_one_model_per_delegate(self): + lowered = _lower([CoreAIPartitioner()]) + ((forward, _),) = _manifests(lowered, "forward") + ((prefill, _),) = _manifests(lowered, "prefill") + self.assertEqual((forward["function"], prefill["function"]), ("main", "main")) + self.assertNotIn("multifunction", forward) + self.assertNotEqual(forward["export_hash"], prefill["export_hash"]) + + def test_mismatched_compile_specs_raise(self): + partitioners = { + "forward": [CoreAIPartitioner(multifunction=True)], + "prefill": [ + CoreAIPartitioner(multifunction=True, min_deployment_version="27.0") + ], + } + with self.assertRaisesRegex(ValueError, "identical Core AI compile specs"): + _lower(partitioners) + + def test_option_on_only_some_methods_raises(self): + partitioners = { + "forward": [CoreAIPartitioner(multifunction=True)], + "prefill": [CoreAIPartitioner()], + } + with self.assertRaisesRegex(ValueError, r"\['prefill'\]"): + _lower(partitioners) + + @mock.patch(_MOCK_BUILD) + def test_aot_compiled_methods_share_archs(self, build): + def fake_build(aimodel_path, out_dir, opts): + for arch in opts["architectures"]: + bundle = out_dir / f"model.{arch}.aimodelc" + bundle.mkdir(parents=True) + (bundle / "model.mil").write_bytes(b"fake-compiled") + + build.side_effect = fake_build + aot = AOTCompileConfig(platform="iOS", architectures=["h15g", "h16"]) + lowered = _lower( + [CoreAIPartitioner(multifunction=True, aot_compile_config=aot)] + ) + ((forward, _),) = _manifests(lowered, "forward") + ((prefill, _),) = _manifests(lowered, "prefill") + self.assertEqual(build.call_count, 1) + self.assertEqual(forward["packaging"], "aimodelc") + self.assertEqual(sorted(forward["archs"]), ["h15g", "h16"]) + self.assertEqual(forward["archs"], prefill["archs"]) + self.assertEqual( + (forward["function"], prefill["function"]), ("forward_0", "prefill_0") + ) + + +class MultifunctionEnumeratedShapesTest(unittest.TestCase): + def _shapes_by_function(self, manifest, prefix): + functions = manifest["enumerated_functions"] + for entry in functions: + self.assertTrue(entry["function"].startswith(prefix), entry["function"]) + return sorted(tuple(map(tuple, f["input_shapes"])) for f in functions) + + def test_enumerated_method_lists_per_shape_functions(self): + lowered = _lower_enumerated() + [(forward, _)] = _manifests(lowered, "forward") + [(prefill, _)] = _manifests(lowered, "prefill") + self.assertEqual(forward["function"], "forward_0") + self.assertNotIn("enumerated_functions", forward) + self.assertNotIn("function", prefill) + self.assertEqual( + self._shapes_by_function(prefill, "prefill_0_"), + [((4, _WIDTH),), ((8, _WIDTH),), ((16, _WIDTH),)], + ) + self.assertEqual(forward["export_hash"], prefill["export_hash"]) + self.assertTrue(forward["multifunction"] and prefill["multifunction"]) + + def test_graph_break_enumerates_every_partition(self): + lowered = _lower_enumerated(graph_break=True, rows=(4, 16)) + prefill = [m for m, _ in _manifests(lowered, "prefill")] + self.assertEqual(len(prefill), 2) + for index, manifest in enumerate(prefill): + self.assertEqual( + self._shapes_by_function(manifest, f"prefill_{index}_"), + [((4, _WIDTH),), ((16, _WIDTH),)], + ) + forward = [m["function"] for m, _ in _manifests(lowered, "forward")] + self.assertEqual(forward, ["forward_0", "forward_1"]) + + def test_mixed_partitioners_name_the_missing_flag(self): + with self.assertRaisesRegex( + ValueError, r"multifunction=True must be set.*\['prefill'\]" + ): + _lower( + { + "forward": [CoreAIPartitioner(multifunction=True)], + "prefill": [CoreAIPartitioner()], + } + ) + + def test_duplicate_function_names_are_rejected(self): + _check_unique_functions( + [ + {"function": "forward_0"}, + {"enumerated_functions": [{"function": "forward_0_s0_4"}]}, + ] + ) + with self.assertRaisesRegex(ValueError, "'forward_0_s0_4' twice"): + _check_unique_functions( + [ + {"enumerated_functions": [{"function": "forward_0_s0_4"}]}, + {"function": "forward_0_s0_4"}, + ] + ) + + def test_other_compile_specs_must_still_match(self): + dim = torch.export.Dim("rows", min=1, max=64) + model = _Stack().eval() + with self.assertRaisesRegex(ValueError, "identical Core AI compile specs"): + to_edge_transform_and_lower( + { + "forward": torch.export.export(model, (torch.randn(1, _WIDTH),)), + "prefill": torch.export.export( + model, (torch.randn(8, _WIDTH),), dynamic_shapes=({0: dim},) + ), + }, + transform_passes=get_default_passes(), + partitioner={ + "forward": [CoreAIPartitioner(multifunction=True)], + "prefill": [ + CoreAIPartitioner( + multifunction=True, + input_enumerations=[{0: [4, 8]}], + min_deployment_version="27.1", + ) + ], + }, + compile_config=get_default_compile_config(), + ) + + +def _runtime_available(): + try: + from executorch.extension.pybindings.portable_lib import _is_available + except ImportError: + return False + return _is_available("CoreAIBackend") + + +@unittest.skipUnless( + _coreai_build_available(), + "requires macOS with the Metal Toolchain (xcrun coreai-build)", +) +class MultifunctionAOTCompileTest(unittest.TestCase): + def test_real_build_shares_compiled_bundles(self): + aot = AOTCompileConfig(platform="macOS", architectures=["h16c"]) + lowered = _lower( + [CoreAIPartitioner(multifunction=True, aot_compile_config=aot)], + graph_break=True, + ) + manifests = [ + m + for method in ("forward", "prefill") + for m, _ in _manifests(lowered, method) + ] + self.assertEqual( + [m["function"] for m in manifests], + ["forward_0", "forward_1", "prefill_0", "prefill_1"], + ) + self.assertEqual(len({json.dumps(m["archs"]) for m in manifests}), 1) + self.assertEqual(list(manifests[0]["archs"]), ["h16c"]) + + +@unittest.skipUnless(_runtime_available(), "requires the Core AI Python runtime") +class MultifunctionRuntimeTest(unittest.TestCase): + def _run_all(self, lowered, inputs): + from executorch.extension.pybindings.portable_lib import ( + _load_program_from_buffer, + Verification, + ) + + buffer = lowered.to_executorch().buffer + outputs = {} + with tempfile.TemporaryDirectory(prefix="coreai-multifunction-") as cache: + program = _load_program_from_buffer( + buffer, + program_verification=Verification.Minimal, + backend_options={"CoreAIBackend": {"coreai_cache_dir": cache}}, + ) + methods = {name: program.load_method(name) for name in inputs} + for name, args in inputs.items(): + outputs[name] = methods[name].call(args, clone_outputs=True)[0] + del methods, program + return outputs + + def _check(self, graph_break): + torch.manual_seed(0) + model = _Stack().eval() + inputs = _inputs() + lowered = _lower( + [CoreAIPartitioner(multifunction=True)], + graph_break=graph_break, + model=model, + inputs=inputs, + ) + outputs = self._run_all(lowered, inputs) + with torch.no_grad(): + for name, args in inputs.items(): + torch.testing.assert_close( + outputs[name], model(*args), atol=1e-2, rtol=1e-2, msg=name + ) + + def test_each_method_runs_its_function(self): + self._check(graph_break=False) + + def test_graph_break_methods_run(self): + self._check(graph_break=True) + + def test_enumerated_shapes_dispatch_by_input_shape(self): + torch.manual_seed(0) + model = _Stack().eval() + lowered = _lower_enumerated(graph_break=True, model=model) + from executorch.extension.pybindings.portable_lib import ( + _load_program_from_buffer, + Verification, + ) + + buffer = lowered.to_executorch().buffer + with tempfile.TemporaryDirectory(prefix="coreai-enumerated-") as cache: + program = _load_program_from_buffer( + buffer, + program_verification=Verification.Minimal, + backend_options={"CoreAIBackend": {"coreai_cache_dir": cache}}, + ) + forward = program.load_method("forward") + prefill = program.load_method("prefill") + cases = [(forward, 1)] + [(prefill, rows) for rows in (4, 8, 16)] + with torch.no_grad(): + for method, rows in cases: + x = torch.randn(rows, _WIDTH) + torch.testing.assert_close( + method.call((x,), clone_outputs=True)[0], + model(x), + atol=1e-2, + rtol=1e-2, + msg=f"{rows} rows", + ) + with self.assertRaises(RuntimeError): + prefill.call((torch.randn(5, _WIDTH),), clone_outputs=True) + del forward, prefill, program + + +if __name__ == "__main__": + unittest.main() diff --git a/backends/apple/coreai/test/test_preprocess.py b/backends/apple/coreai/test/test_preprocess.py index 2d8b0d39fd5..32fb21ba229 100644 --- a/backends/apple/coreai/test/test_preprocess.py +++ b/backends/apple/coreai/test/test_preprocess.py @@ -142,7 +142,7 @@ class PortablePreprocessTest(unittest.TestCase): def test_inline_embeds_files_in_nds(self): result = CoreAIBackend.preprocess(_edge_program(), []) manifest = json.loads(result.processed_bytes) - self.assertEqual(manifest["packaging"], "inline") + self.assertEqual(manifest["packaging"], "aimodel") self.assertTrue(manifest["files"], "expected at least one asset file") out = result.data_store_output self.assertIsNotNone(out) @@ -215,7 +215,9 @@ def test_same_program_hash_with_different_metadata_merges(self): (bundle / "metadata.json").write_text( json.dumps({"creationDate": timestamp}) ) - result = _deliver(root, AssetPackaging.INLINE, {"path": bundle.name}) + result = _deliver( + root, AssetPackaging.AIMODEL, {"path": bundle.name}, [{}] + )[0] manifest = json.loads(result.processed_bytes) manifests.append(manifest) self.assertEqual( @@ -241,8 +243,8 @@ def test_aot_namespace_covers_every_architecture(self): for payload in (b"original", b"modified"): (root / archs["h16"] / "model.mil").write_bytes(payload) result = _deliver( - root, AssetPackaging.AOT_COMPILED_INLINE, {"archs": archs} - ) + root, AssetPackaging.AIMODELC, {"archs": archs}, [{}] + )[0] manifest = json.loads(result.processed_bytes) manifests.append(manifest) for arch, name in archs.items(): @@ -378,7 +380,7 @@ def test_inline_embeds_compiled_bundles(self, _build): [_aot_spec({"platform": "iOS", "architectures": ["h15g", "h16"]})], ) manifest = json.loads(result.processed_bytes) - self.assertEqual(manifest["packaging"], "aot_compiled_inline") + self.assertEqual(manifest["packaging"], "aimodelc") self.assertEqual(manifest["platform"], "iOS") self.assertEqual(sorted(manifest["archs"]), ["h15g", "h16"]) # Compiled bundle contents are embedded (files under a *.aimodelc dir). @@ -400,7 +402,7 @@ def test_defaults_to_all_architectures(self, _build): # Empty architectures => coreai-build decides (our fake yields one). result = CoreAIBackend.preprocess(_edge_program(), [_aot_spec({})]) manifest = json.loads(result.processed_bytes) - self.assertEqual(manifest["packaging"], "aot_compiled_inline") + self.assertEqual(manifest["packaging"], "aimodelc") self.assertEqual(manifest["platform"], "macOS") # default self.assertTrue(manifest["archs"]) @@ -448,7 +450,7 @@ def test_aot_inline_embeds_aimodelc(self): CoreAIPartitioner(aot_compile_config=AOTCompileConfig(platform="macOS")) ) lbm, manifest = _lowered_manifest(lowered) - self.assertEqual(manifest["packaging"], "aot_compiled_inline") + self.assertEqual(manifest["packaging"], "aimodelc") self.assertEqual(manifest["platform"], "macOS") self.assertGreaterEqual(len(manifest["archs"]), 1) self.assertTrue(any(".aimodelc/" in f for f in manifest["files"])) diff --git a/backends/apple/coreai/test/test_shape_config.py b/backends/apple/coreai/test/test_shape_config.py index 70dd9963b30..e6dc7b68955 100644 --- a/backends/apple/coreai/test/test_shape_config.py +++ b/backends/apple/coreai/test/test_shape_config.py @@ -4,12 +4,18 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. +import json +import tempfile import unittest +from types import SimpleNamespace + +import sympy import torch import torch.nn as nn from executorch.backends.apple.coreai.compiler.enumerated_shapes import ( + _eval_dim, resolve_input_enumerations, ) from executorch.backends.apple.coreai.partition.partitioner import ( @@ -18,7 +24,10 @@ ) from executorch.exir import to_edge_transform_and_lower from executorch.exir.dialects.edge._ops import EdgeOpOverload -from executorch.exir.lowered_backend_module import executorch_call_delegate +from executorch.exir.lowered_backend_module import ( + executorch_call_delegate, + get_lowered_backend_modules, +) from executorch.exir.pass_base import PassResult @@ -250,10 +259,100 @@ def test_static_dim_fails_lowering(self): partitioner=[CoreAIPartitioner(input_enumerations=[{0: [4, 16]}])], ) + def test_manifest_lists_one_function_per_shape(self): + # The dynamic ``main`` is erased by the specialization, so the manifest + # must name the per-shape functions the runtime can actually bind. + graph = self._lower([{0: [4, 16, 32]}]).exported_program().graph_module + [lbm] = get_lowered_backend_modules(graph) + manifest = json.loads(bytes(lbm.processed_bytes)) + self.assertNotIn("function", manifest) + functions = manifest["enumerated_functions"] + self.assertEqual(len({f["function"] for f in functions}), 3) + for entry in functions: + self.assertTrue(entry["function"].startswith("main_"), entry["function"]) + self.assertEqual( + sorted(tuple(map(tuple, f["input_shapes"])) for f in functions), + [((4, 8),), ((16, 8),), ((32, 8),)], + ) + self.assertEqual(manifest["input_names"], ["input_0"]) + + def test_unenumerated_lowering_keeps_main(self): + graph = ( + to_edge_transform_and_lower( + _dynamic_ep(), partitioner=[CoreAIPartitioner()] + ) + .exported_program() + .graph_module + ) + [lbm] = get_lowered_backend_modules(graph) + manifest = json.loads(bytes(lbm.processed_bytes)) + self.assertEqual(manifest["function"], "main") + self.assertNotIn("enumerated_functions", manifest) + + +def _runtime_available(): + try: + from executorch.extension.pybindings.portable_lib import _is_available + except ImportError: + return False + return _is_available("CoreAIBackend") + + +@unittest.skipUnless(_runtime_available(), "requires the Core AI Python runtime") +class InputEnumerationsRuntimeTest(unittest.TestCase): + def test_runs_each_enumerated_shape_and_rejects_others(self): + from executorch.extension.pybindings.portable_lib import ( + _load_program_from_buffer, + Verification, + ) + + torch.manual_seed(0) + model = _model() + batch = torch.export.Dim("batch", min=1, max=64) + ep = torch.export.export( + model, (torch.randn(2, 8),), dynamic_shapes={"input": {0: batch}} + ) + buffer = ( + to_edge_transform_and_lower( + ep, + partitioner=[CoreAIPartitioner(input_enumerations=[{0: [4, 16, 32]}])], + ) + .to_executorch() + .buffer + ) + with tempfile.TemporaryDirectory(prefix="coreai-enumerated-") as cache: + program = _load_program_from_buffer( + buffer, + program_verification=Verification.Minimal, + backend_options={"CoreAIBackend": {"coreai_cache_dir": cache}}, + ) + method = program.load_method("forward") + with torch.no_grad(): + for rows in (4, 16, 32, 4): + x = torch.randn(rows, 8) + torch.testing.assert_close( + method.call((x,), clone_outputs=True)[0], + model(x), + atol=1e-3, + rtol=1e-3, + msg=f"{rows} rows", + ) + with self.assertRaises(RuntimeError): + method.call((torch.randn(8, 8),), clone_outputs=True) + del method, program + class InputEnumerationsValidationTest(unittest.TestCase): """User-supplied enumerations that cannot be honoured must say so.""" + def test_derived_dim_that_reaches_zero_is_rejected(self): + dim = SimpleNamespace(node=SimpleNamespace(expr=sympy.Symbol("s0") - 1)) + self.assertEqual(_eval_dim(dim, {"s0": 4}, "input_0", 0), 3) + with self.assertRaisesRegex( + ValueError, r"input 'input_0' dim 0 is s0 - 1, which is 0 when s0=1" + ): + _eval_dim(dim, {"s0": 1}, "input_0", 0) + def test_conflicting_values_for_one_symbol_are_rejected(self): """Two inputs tied to the same Dim cannot enumerate differently. From f077a9dd84cb54361bd47be44d57953ec7b5cc78 Mon Sep 17 00:00:00 2001 From: Baris <90050875+bdemirb@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:48:04 +0000 Subject: [PATCH 064/128] Arm backend: Fix semihosting CLI operand checks (#24508) Guard -i, -m, and -o before consuming argv[++i]. Avoid unsafe paired argument logging in semihosting mode. Change-Id: I908a1d111da41fa0e7dcb42e2b03dbb97229416d cc @digantdesai @freddan80 @per @zingo @oscarandersson8218 @mansnils @Sebastian-Larsson @robell @rascani Signed-off-by: Baris Demir --- .../executor_runner/arm_executor_runner.cpp | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/examples/arm/executor_runner/arm_executor_runner.cpp b/examples/arm/executor_runner/arm_executor_runner.cpp index f37844ced65..e2ce093c50e 100644 --- a/examples/arm/executor_runner/arm_executor_runner.cpp +++ b/examples/arm/executor_runner/arm_executor_runner.cpp @@ -1478,8 +1478,8 @@ int main(int argc, const char* argv[]) { } #endif ET_LOG(Info, " %s", argv[0]); - for (int i = 1; i + 1 < argc; i += 2) { - ET_LOG(Info, " %s %s", argv[i], argv[i + 1]); + for (int i = 1; i < argc; i++) { + ET_LOG(Info, " %s", argv[i]); } #else (void)argc; @@ -1508,10 +1508,14 @@ int main(int argc, const char* argv[]) { input_file_allocation_pool_size, input_file_allocation_pool); /* parse input parameters */ - for (int i = 0; i < argc; i++) { + for (int i = 1; i < argc; i++) { if (std::strcmp(argv[i], "-i") == 0) { + if (++i >= argc || argv[i] == nullptr) { + ET_LOG(Fatal, "Missing value for -i"); + return 1; + } // input file, read the data into memory - const char* input_tensor_filename = argv[++i]; + const char* input_tensor_filename = argv[i]; const printf_size_t nbr_inputs = input_buffers.size() + 1; ET_LOG( Info, @@ -1531,7 +1535,11 @@ int main(int argc, const char* argv[]) { input_buffers.push_back(std::make_pair(buffer, buffer_size)); ctx.input_filenames.push_back(input_tensor_filename); } else if (std::strcmp(argv[i], "-m") == 0) { - const char* pte_filename = argv[++i]; + if (++i >= argc || argv[i] == nullptr) { + ET_LOG(Fatal, "Missing value for -m"); + return 1; + } + const char* pte_filename = argv[i]; ET_LOG(Info, "Reading pte model from file %s", pte_filename); auto [buffer, buffer_size] = read_binary_file(pte_filename, ctx.input_file_allocator.value()); @@ -1543,8 +1551,12 @@ int main(int argc, const char* argv[]) { model_data = reinterpret_cast(buffer); model_size = buffer_size; } else if (std::strcmp(argv[i], "-o") == 0) { + if (++i >= argc || argv[i] == nullptr) { + ET_LOG(Fatal, "Missing value for -o"); + return 1; + } // store the base filename to write output to. - ctx.output_basename = argv[++i]; + ctx.output_basename = argv[i]; } else if (std::strcmp(argv[i], "--server_mode") == 0) { if (++i >= argc) { ET_LOG(Fatal, "--server_mode requires true or false"); From 42fd7f58151d5a06072961bbab0ad60a41beba81 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Wed, 4 Nov 2026 11:50:52 -0800 Subject: [PATCH 065/128] MLX: lower aten.gather and aten.index_put (#24598) ## What is slow or broken today `aten.gather` and `aten.index_put` have no MLX handler, so they run on the CPU. Every CPU op cuts the graph, and the MLX delegate is split into pieces around it, with a copy between CPU and GPU memory at each cut. Mixture-of-experts (MoE) layers use both ops in every layer: ```python weights = scores.gather(1, selected) # aten.gather slots[expert, slot] = x[token] # aten.index_put out.index_add_(0, token, weights * y) # aten.index_put with accumulate=True ``` So a deep MoE model turns into hundreds of small delegates, and the CPU `index_put` calls are slow on their own. Separately, `x[ids]` with one index tensor of the same rank as a 2-D or larger input is lowered to `take_along_axis`. That gives the wrong shape and values, or fails at run time. A convolution written with `padding="valid"` or `padding="same"` also runs on the CPU, so a large patch embedding in a vision model takes the slow CPU path. ## What this change does - `aten.gather` lowers to the existing `TakeAlongAxisNode`. When the index is smaller than the input on another dim, the input is sliced first, because `take_along_axis` would broadcast there instead. - `aten.index_put` lowers to a new `ScatterNode`, which runs `mlx::scatter`, or `mlx::scatter_add` when `accumulate=True`. The handler reshapes the values into the layout MLX expects. Integer index tensors on any axes work, with full slices (`None`) between them, broadcast indices and broadcast values. - `ScatterNode` has a `reduce` string field (`"none"` overwrites, `"sum"` adds) rather than a bool, so other MLX scatter reductions can be added later without a second field. It is added at the end of the op union, so existing `.pte` files still load. - `mlx::scatter` and `mlx::take_along_axis` do not check index values, so the runtime clamps them to the axis range on the device. A bad index then writes or reads the first or last slice instead of memory outside the tensor. Eager PyTorch raises in that case. A host-side check would have to wait for indices computed on the GPU, which made a MoE block several times slower. On an empty axis there is nothing to clamp to, so `index_put` returns the input and a non-empty `gather` raises, as eager does. - Forms that MLX cannot run correctly are refused by the partitioner, so they stay on the CPU: - 8-byte dtypes such as int64, because MLX's Metal scatter kernels reject them at run time - boolean mask indices - `accumulate=True` on bool - `aten.index.Tensor` with a single index now always lowers to `take`, which matches PyTorch for any index rank. - Convolutions written with `padding="valid"` or `padding="same"` now run on MLX. They decompose to `aten.convolution`, which the MLX handler used to refuse unless the convolution was transposed. For example, a vision patch embedding `nn.Conv2d(3, 768, 16, stride=16, padding="valid")` used to fall back to the CPU. When `"same"` needs more padding on one side, PyTorch adds a pad op before the convolution, and MLX already runs that. ## Not covered These still run on the CPU (correct, but they split the graph). They can be follow-ups: - `index_put` whose values need broadcasting along a dynamic dim, such as `x[idx] = 0.0` with a dynamic-length `idx` - `gather` where the index is smaller than the input on a dynamic dim - `index_put` with a constant boolean mask (`aten.index` already turns those into positions) - out-of-range index checks for `aten.scatter_add` (missing before this change too) ## What was tested On an M-series Mac: - New op tests `gather` and `index_put`, compared with eager PyTorch: float32, float16 and bfloat16 (also with accumulate), int32, bool, int32 and int64 indices, a dynamic size, negative and repeated indices, leading and separated indexed axes, 2-D indices, broadcast indices and values, a 0-d index, and a gather that slices more than one dim. Each config also checks which MLX nodes were emitted. - New `index` op test configs: a 2-D index into a 2-D table, and an index on the second axis. Both fail without the `take` fix. - New partitioner tests: - A small MoE block lowers to 2 delegates, and only the int64 expert count stays on the CPU between them. - A cache write into a mutable buffer stays in the delegate, keeps its writes across calls, and clamps positions past either end. - A gather clamps indices past either end. - On an empty axis, writing or reading nothing works, and a non-empty gather raises. - Each refused form gives its refusal reason, stays on the CPU, and returns an exact result. - A new `conv_padding` op test covers `"valid"` and `"same"` for conv1d, conv2d and conv3d, with odd, even and non-square kernels, stride, dilation and groups, in float32 and bfloat16. Each config checks that MLX emitted the conv node, and the pad node when the padding is uneven. - The op test runner now reports failures it used to hide. `run_all_tests --isolate` used to pass every test without running it, and a test that lowered to no MLX delegate ended the whole run, or hung a parallel run, instead of failing. A new `test_run_all_tests.py` covers both. - The full MLX op suite and the MLX Python test files pass. - On a 4-layer MoE model (16 experts, top-2, 50 tokens): 13 delegates before, 5 after. The CPU ops left are the int64 `scatter_add` calls, which MLX cannot run on the GPU. Time per call went from about 12 ms to about 5.5 ms. Output matches eager within 2.4e-7. --- .github/workflows/mlx.yml | 1 + backends/mlx/llm/source_transformation.py | 5 +- backends/mlx/ops.py | 217 +++++++++++-- backends/mlx/pte_inspector.py | 3 +- backends/mlx/runtime/MLXInterpreter.h | 79 ++++- backends/mlx/serialization/schema.fbs | 24 +- backends/mlx/test/run_all_tests.py | 3 +- backends/mlx/test/test_ops.py | 367 +++++++++++++++++++++- backends/mlx/test/test_partitioner.py | 198 ++++++++++++ backends/mlx/test/test_run_all_tests.py | 41 +++ backends/mlx/test/test_utils.py | 25 ++ 11 files changed, 908 insertions(+), 55 deletions(-) create mode 100644 backends/mlx/test/test_run_all_tests.py diff --git a/.github/workflows/mlx.yml b/.github/workflows/mlx.yml index 566677d489e..203c1283c63 100644 --- a/.github/workflows/mlx.yml +++ b/.github/workflows/mlx.yml @@ -131,6 +131,7 @@ jobs: backends/mlx/test/test_serialization_dedup.py \ backends/mlx/test/test_slot_recycling.py \ backends/mlx/test/test_sample.py \ + backends/mlx/test/test_run_all_tests.py \ examples/models/gemma4_31b/tests/test_mlx_pipeline.py \ examples/models/muse-glimmer/tests/test_mlx_pipeline.py \ examples/models/muse-glimmer/tests/test_mlx_offgraph.py \ diff --git a/backends/mlx/llm/source_transformation.py b/backends/mlx/llm/source_transformation.py index 44bbac865bc..4fa14d0fea3 100644 --- a/backends/mlx/llm/source_transformation.py +++ b/backends/mlx/llm/source_transformation.py @@ -60,8 +60,9 @@ def replace_et_kv_cache_with_mlx( Replace ET's KVCache with MLX-optimized KVCache. Recursively finds all KVCache instances (from examples/models/llama/attention.py) - and replaces them with KVCache, which uses mlx::kv_cache_update instead of - unsupported index_put operations. + and replaces them with KVCache, which writes the new positions into the + cache in place with mlx::kv_cache_update. A plain index_put lowers to a + scatter that copies the whole cache on every step. Args: module: Model to modify (in place) diff --git a/backends/mlx/ops.py b/backends/mlx/ops.py index b4704067402..c73331431db 100644 --- a/backends/mlx/ops.py +++ b/backends/mlx/ops.py @@ -134,6 +134,7 @@ RoundNode, RsqrtNode, ScatterAddNode, + ScatterNode, SdpaNode, SigmoidNode, SignNode, @@ -2481,33 +2482,16 @@ def _index_handler(P: MLXProgramBuilder, n: Node) -> Slot: non_none = _constant_mask_as_positions(P, n, non_none) if len(non_none) == 1: - axis, idx, idx_meta = non_none[0] - ndim_match = ( - x_meta is not None - and idx_meta is not None - and len(x_meta.shape) == len(idx_meta.shape) - ) + axis, idx, _ = non_none[0] out = P.make_or_get_slot(n) - if ndim_match: - # Same ndim: use TakeAlongAxisNode (element-wise gather) - P.emit( - TakeAlongAxisNode( - x=P.slot_to_tid(x), - indices=P.slot_to_tid(idx), - out=P.slot_to_tid(out), - axis=axis, - ) - ) - else: - # Different ndim (e.g. 1D indices into 3D tensor): use TakeNode - P.emit( - TakeNode( - x=P.slot_to_tid(x), - index=IntOrVidOrTid.from_tid(P.slot_to_tid(idx)), - out=P.slot_to_tid(out), - axis=axis, - ) + P.emit( + TakeNode( + x=P.slot_to_tid(x), + index=IntOrVidOrTid.from_tid(P.slot_to_tid(idx)), + out=P.slot_to_tid(out), + axis=axis, ) + ) return out # Multi-index: use GatherNode (maps to mlx::gather) @@ -2619,6 +2603,62 @@ def _index_select_handler(P: MLXProgramBuilder, n: Node) -> Slot: return out +@REGISTRY.register(target=[torch.ops.aten.gather.default]) +def _gather_handler(P: MLXProgramBuilder, n: Node) -> Slot: + """Handle aten.gather: pick elements of x along dim at the index positions. + + Maps to mlx::take_along_axis, which broadcasts the index against x on the + other axes. aten.gather instead reads only the first index.size(d) entries + of x there, so x is sliced to the index shape first where the two differ. + + Unlike eager, an out-of-range index does not raise: the runtime clamps it + to the first or last element so the read stays inside x. + """ + args = P.args(n) + require_args(args, 3, 3, "aten.gather") + # sparse_grad only changes the backward pass. + require_kwargs(P.kwargs(n), {"sparse_grad"}, "aten.gather") + x, dim, index = args + x_shape = n.args[0].meta["val"].shape + index_shape = n.args[2].meta["val"].shape + if len(x_shape) == 0 or len(index_shape) != len(x_shape): + raise NotImplementedError( + f"aten.gather: input of rank {len(x_shape)} with index of rank " + f"{len(index_shape)} is not supported" + ) + require_static_int(dim, "dim", "aten.gather") + axis = dim % len(x_shape) + for d, (x_size, index_size) in enumerate(zip(x_shape, index_shape)): + if d == axis or statically_known_true(x_size == index_size): + continue + if not isinstance(index_size, int): + raise NotImplementedError( + f"aten.gather: index size {index_size} on dim {d} may differ " + f"from the input size {x_size}" + ) + _, sliced = P.make_tmp_slot() + P.emit( + SliceNode( + x=P.slot_to_tid(x), + out=P.slot_to_tid(sliced), + axis=IntOrVid.from_literal(d), + start=IntOrVid.from_literal(0), + stop=IntOrVid.from_literal(index_size), + ) + ) + x = sliced + out = P.make_or_get_slot(n) + P.emit( + TakeAlongAxisNode( + x=P.slot_to_tid(x), + indices=P.slot_to_tid(index), + out=P.slot_to_tid(out), + axis=axis, + ) + ) + return out + + @REGISTRY.register(target=[torch.ops.aten.slice_scatter.default]) def _slice_scatter_handler(P: MLXProgramBuilder, n: Node) -> Slot: """Handle aten.slice_scatter: return a copy of self with self[dim][start:end:step] = src.""" @@ -2666,7 +2706,7 @@ def _scatter_add_handler(P: MLXProgramBuilder, n: Node) -> Slot: scatter_add(self, dim, index, src) -> Tensor - Maps to mlx::scatter_add(a, indices, updates, axis). + Maps to mlx::scatter_add_axis(a, indices, updates, axis). """ args = P.args(n) require_args(args, 4, 4, "aten.scatter_add") @@ -2689,6 +2729,114 @@ def _scatter_add_handler(P: MLXProgramBuilder, n: Node) -> Slot: return out +@REGISTRY.register(target=[torch.ops.aten.index_put.default]) +def _index_put_handler(P: MLXProgramBuilder, n: Node) -> Slot: + """Handle aten.index_put: write values into self at the indexed positions. + + index_put(self, indices, values, accumulate=False) -> Tensor + + Maps to mlx::scatter, or mlx::scatter_add when accumulate is set. MLX takes + the updates as broadcast(indices).shape followed by one slice of self. So + the values are broadcast to the shape self[indices] has in PyTorch, the + index dimensions are moved to the front, and a unit dimension is inserted + for each indexed axis. + + Unlike eager, an out-of-range index does not raise: the runtime clamps it + to the first or last slice so the write stays inside self. + """ + args = P.args(n) + require_args(args, 3, 4, "aten.index_put") + require_kwargs(P.kwargs(n), set(), "aten.index_put") + x, indices, values = args[:3] + accumulate = args[3] if len(args) > 3 else False + dtype = n.meta["val"].dtype + # MLX's Metal scatter kernels reject 8-byte element types at run time. + if dtype.itemsize == 8: + raise NotImplementedError(f"aten.index_put on {dtype} is not supported") + if accumulate and dtype == torch.bool: + raise NotImplementedError("aten.index_put with accumulate on bool") + + index_vals = [i.meta["val"] for i in n.args[1] if i is not None] + axes = [d for d, i in enumerate(n.args[1]) if i is not None] + if not axes: + raise NotImplementedError("aten.index_put: all indices are None") + if any(i.dtype not in (torch.int32, torch.int64) for i in index_vals): + raise NotImplementedError( + "aten.index_put: only int32 and int64 index tensors are supported" + ) + + # PyTorch puts the index dimensions where the indexed axes were when those + # axes are adjacent, and in front otherwise. + x_shape = list(n.args[0].meta["val"].shape) + index_shape = list(torch.broadcast_shapes(*(i.shape for i in index_vals))) + rest = [size for d, size in enumerate(x_shape) if d not in axes] + lead = axes[0] if axes == list(range(axes[0], axes[-1] + 1)) else 0 + target = rest[:lead] + index_shape + rest[lead:] + + values_shape = list(n.args[2].meta["val"].shape) + offset = len(target) - len(values_shape) + if offset or not all( + statically_known_true(v == t) for v, t in zip(values_shape, target) + ): + shape = [] + for d, size in enumerate(target): + if d >= offset and statically_known_true(values_shape[d - offset] == size): + shape.append(IntOrVid.from_literal(-1)) + elif isinstance(size, int): + shape.append(IntOrVid.from_literal(size)) + else: + raise NotImplementedError( + f"aten.index_put: cannot broadcast values to dynamic size {size}" + ) + _, broadcast = P.make_tmp_slot() + P.emit( + BroadcastToNode( + x=P.slot_to_tid(values), + out=P.slot_to_tid(broadcast), + shape=shape, + ) + ) + values = broadcast + + if lead and index_shape: + index_ndim = len(index_shape) + _, transposed = P.make_tmp_slot() + P.emit( + TransposeNode( + x=P.slot_to_tid(values), + out=P.slot_to_tid(transposed), + perm=list(range(lead, lead + index_ndim)) + + list(range(lead)) + + list(range(lead + index_ndim, len(target))), + ) + ) + values = transposed + + for axis in axes: + _, expanded = P.make_tmp_slot() + P.emit( + ExpandDimsNode( + x=P.slot_to_tid(values), + out=P.slot_to_tid(expanded), + axis=len(index_shape) + axis, + ) + ) + values = expanded + + out = P.make_or_get_slot(n) + P.emit( + ScatterNode( + x=P.slot_to_tid(x), + indices=[P.slot_to_tid(i) for i in indices if i is not None], + updates=P.slot_to_tid(values), + out=P.slot_to_tid(out), + axes=axes, + reduce="sum" if accumulate else "none", + ) + ) + return out + + @REGISTRY.register(target=[torch.ops.aten.select.int, torch.ops.aten.select_copy.int]) def _select_handler(P: MLXProgramBuilder, n: Node) -> Slot: """ @@ -3739,8 +3887,10 @@ def _convolution_handler(P: MLXProgramBuilder, n: Node) -> Slot: Args layout: convolution(input, weight, bias, stride, padding, dilation, transposed, output_padding, groups) - This op appears when PyTorch doesn't decompose to specific conv ops - (e.g. grouped conv_transpose). + Conv ops without a handler decompose to this op. For example + conv2d(padding="valid") arrives as convolution with zero padding, and + padding="same" arrives as constant_pad_nd + convolution when one side needs + more padding than the other. Grouped conv_transpose arrives here too. """ raw_args = n.args x_node, w_node = raw_args[0], raw_args[1] @@ -3748,11 +3898,6 @@ def _convolution_handler(P: MLXProgramBuilder, n: Node) -> Slot: transposed = raw_args[6] if len(raw_args) > 6 else False groups = raw_args[8] if len(raw_args) > 8 else 1 - if not transposed: - raise ValueError( - "aten.convolution with transposed=False: use aten.conv{1,2,3}d instead" - ) - x_meta = x_node.meta.get("val") if x_meta is None: raise ValueError("aten.convolution: input shape metadata required") @@ -3761,6 +3906,12 @@ def _convolution_handler(P: MLXProgramBuilder, n: Node) -> Slot: stride = _normalize_conv_param(raw_args[3] if len(raw_args) > 3 else 1, ndim, 1) padding = _normalize_conv_param(raw_args[4] if len(raw_args) > 4 else 0, ndim, 0) dilation = _normalize_conv_param(raw_args[5] if len(raw_args) > 5 else 1, ndim, 1) + + if not transposed: + return _emit_conv( + P, n, x_node, w_node, bias_node, stride, padding, dilation, groups, ndim + ) + output_padding = _normalize_conv_param( raw_args[7] if len(raw_args) > 7 else 0, ndim, 0 ) diff --git a/backends/mlx/pte_inspector.py b/backends/mlx/pte_inspector.py index 20ec9e9d9df..08a048a43f6 100644 --- a/backends/mlx/pte_inspector.py +++ b/backends/mlx/pte_inspector.py @@ -628,7 +628,7 @@ def show_mlx_instructions(pte_data: bytes) -> None: # noqa: C901 mlx_delegates = _find_mlx_delegates(pte_data) if not mlx_delegates: print("No MLX delegates found in this PTE file.", file=sys.stderr) - sys.exit(1) + return if len(mlx_delegates) > 1: print( @@ -747,7 +747,6 @@ def show_mlx_instructions(pte_data: bytes) -> None: # noqa: C901 except Exception as e: print(f"Error showing MLX instructions: {e}", file=sys.stderr) traceback.print_exc() - sys.exit(1) def main(): # noqa: C901 diff --git a/backends/mlx/runtime/MLXInterpreter.h b/backends/mlx/runtime/MLXInterpreter.h index 8e046f1609b..1dbadac86f9 100644 --- a/backends/mlx/runtime/MLXInterpreter.h +++ b/backends/mlx/runtime/MLXInterpreter.h @@ -186,10 +186,18 @@ inline void exec_take_along_axis( const TakeAlongAxisNode& n, ExecutionState& st, StreamOrDevice s) { - st.set_tensor( - n.out, - take_along_axis( - st.const_tensor_ref(n.x), st.const_tensor_ref(n.indices), n.axis, s)); + const auto& x = st.const_tensor_ref(n.x); + const int axis = + normalize_axis(n.axis, static_cast(x.ndim()), "TakeAlongAxis"); + const int size = x.shape(axis); + const auto& indices = st.const_tensor_ref(n.indices); + // An empty axis has no position to clamp to. + if (size == 0 && indices.size() != 0) { + throw std::runtime_error("TakeAlongAxis: cannot take from an empty axis"); + } + // Clamped like ScatterNode: mlx::take_along_axis would read out of range. + const auto clamped = clip(indices, array(-size), array(size - 1), s); + st.set_tensor(n.out, take_along_axis(x, clamped, axis, s)); } inline void exec_take(const TakeNode& n, ExecutionState& st, StreamOrDevice s) { @@ -897,6 +905,66 @@ inline void exec_scatter_add( st.set_tensor(n.out, scatter_add_axis(x, indices, updates, n.axis, s)); } +inline void +exec_scatter(const ScatterNode& n, ExecutionState& st, StreamOrDevice s) { + const auto& x = st.const_tensor_ref(n.x); + const int rank = static_cast(x.ndim()); + + if (n.indices.size() != n.axes.size()) { + throw std::runtime_error( + "ScatterNode: indices count (" + std::to_string(n.indices.size()) + + ") must match axes count (" + std::to_string(n.axes.size()) + ")"); + } + + for (auto axis : n.axes) { + if (axis < 0 || axis >= rank) { + throw std::runtime_error( + "ScatterNode: axis " + std::to_string(axis) + + " out of range for input with ndim " + std::to_string(rank)); + } + } + + std::vector indices; + indices.reserve(n.indices.size()); + size_t index_ndim = 0; + for (auto tid : n.indices) { + indices.push_back(st.const_tensor_ref(tid)); + index_ndim = std::max(index_ndim, indices.back().ndim()); + } + + // mlx::scatter checks neither the index values nor where a slice ends, and + // either one out of range would write outside x. The handler always emits + // a slice of size 1 on each indexed axis. + const auto& updates = st.const_tensor_ref(n.updates); + for (auto axis : n.axes) { + const auto dim = index_ndim + static_cast(axis); + if (updates.ndim() <= dim || updates.shape()[dim] != 1) { + throw std::runtime_error( + "ScatterNode: updates must have size 1 on indexed axis " + + std::to_string(axis)); + } + } + // Clamped on the device: a host range check has to wait for indices that + // are computed on the device, which made a MoE block several times slower. + for (size_t i = 0; i < indices.size(); ++i) { + const int size = x.shape(n.axes[i]); + if (size == 0) { + // x is empty, so there is nowhere to write. + st.set_tensor(n.out, x); + return; + } + indices[i] = clip(indices[i], array(-size), array(size - 1), s); + } + + if (n.reduce == "none") { + st.set_tensor(n.out, scatter(x, indices, updates, n.axes, s)); + } else if (n.reduce == "sum") { + st.set_tensor(n.out, scatter_add(x, indices, updates, n.axes, s)); + } else { + throw std::runtime_error("ScatterNode: unsupported reduce: " + n.reduce); + } +} + inline void exec_slice(const SliceNode& n, ExecutionState& st, StreamOrDevice s) { const array& x = st.const_tensor_ref(n.x); @@ -2622,6 +2690,9 @@ class Interpreter { case OpCode::SCATTER_ADD: ops::exec_scatter_add(std::get(instr.node), st, s); break; + case OpCode::SCATTER: + ops::exec_scatter(std::get(instr.node), st, s); + break; case OpCode::GATHER_MM: ops::exec_gather_mm(std::get(instr.node), st, s); break; diff --git a/backends/mlx/serialization/schema.fbs b/backends/mlx/serialization/schema.fbs index 2743c093a17..1a0b0a468a8 100644 --- a/backends/mlx/serialization/schema.fbs +++ b/backends/mlx/serialization/schema.fbs @@ -103,6 +103,9 @@ table TileNode { reps: [IntOrVid] (required); } +// Index values are clamped like ScatterNode's, so a bad index reads the +// first or last element instead of memory outside x. A non-empty take from +// an empty axis fails. table TakeAlongAxisNode { x: Tid (required); indices: Tid (required); @@ -463,7 +466,7 @@ table QuantizedMatmulNode { } // Scatter-add: accumulate updates into input at index positions along an axis -// Maps to mlx::scatter_add(a, indices, updates, axis) +// Maps to mlx::scatter_add_axis(a, indices, updates, axis) table ScatterAddNode { x: Tid (required); // Input tensor to scatter into indices: Tid (required); // Index tensor @@ -472,6 +475,22 @@ table ScatterAddNode { axis: int32; // Dimension to scatter along } +// Scatter: write updates into x at the positions given by one index tensor +// per indexed axis. Maps to mlx::scatter, or mlx::scatter_add for "sum". +// Index values are clamped to [-size, size - 1], where negative values count +// from the end as in PyTorch. So a bad index updates the first or last slice +// instead of memory outside x. If an indexed axis is empty, x is returned. +table ScatterNode { + x: Tid (required); + indices: [Tid] (required); // Index tensors (one per indexed axis) + // Shape: broadcast(indices).shape followed by x.shape, with size 1 on + // every indexed axis, so rank(updates) = rank(indices) + rank(x). + updates: Tid (required); + out: Tid (required); + axes: [int32] (required); // Which axes the indices address + reduce: string (required); // "none" overwrites x at the indices, "sum" adds into x +} + table ConcatenateNode { tensors: [Tid] (required); // List of tensors to concatenate out: Tid (required); @@ -1211,7 +1230,8 @@ union OpNode { UpdateAndAttendNode, TruncNode, FlipNode, - CummaxNode + CummaxNode, + ScatterNode // BC: Add new op nodes here (append only) } diff --git a/backends/mlx/test/run_all_tests.py b/backends/mlx/test/run_all_tests.py index 3cda35da275..13c165013f8 100644 --- a/backends/mlx/test/run_all_tests.py +++ b/backends/mlx/test/run_all_tests.py @@ -181,7 +181,8 @@ def _run_test_in_subprocess( "-m", "executorch.backends.mlx.test.test_utils", config_name, - "run", + "--timeout", + str(timeout), ] if verbose: cmd.append("--verbose") diff --git a/backends/mlx/test/test_ops.py b/backends/mlx/test/test_ops.py index 8f049616c6d..4160f60c65e 100644 --- a/backends/mlx/test/test_ops.py +++ b/backends/mlx/test/test_ops.py @@ -3843,6 +3843,110 @@ def create_model(self) -> nn.Module: ) +@register_test +class ConvStringPaddingTest(OpTestCase): + """Test case for conv{1,2,3}d with padding="valid" or "same". + + These export as aten.conv*d.padding, which decomposes to aten.convolution + (plus constant_pad_nd when "same" needs more padding on one side). + """ + + name = "conv_padding" + rtol = 1e-4 + atol = 1e-4 + + def __init__( + self, + ndim: int = 2, + padding: str = "same", + kernel_size=3, + stride: int = 1, + dilation: int = 1, + groups: int = 1, + dtype: torch.dtype = torch.float32, + in_channels: int = 4, + out_channels: int = 8, + input_size: int = 11, + ): + self.ndim = ndim + self.padding = padding + self.kernel_size = kernel_size + self.stride = stride + self.dilation = dilation + self.groups = groups + self.dtype = dtype + self.in_channels = in_channels + self.out_channels = out_channels + self.input_size = input_size + if dtype == torch.bfloat16: + self.rtol = 1e-2 + self.atol = 1e-2 + + kernel = kernel_size if isinstance(kernel_size, tuple) else (kernel_size,) + # "same" pads one side more when the total padding dilation * (k - 1) is odd. + uneven = padding == "same" and any(dilation * (k - 1) % 2 for k in kernel) + self.expected_node_counts = {f"Conv{ndim}DNode": 1, "PadNode": int(uneven)} + + parts = [ + f"conv{ndim}d", + padding, + "k" + "x".join(str(k) for k in kernel), + ] + if stride != 1: + parts.append(f"s{stride}") + if dilation != 1: + parts.append(f"d{dilation}") + if groups != 1: + parts.append(f"g{groups}") + if dtype != torch.float32: + parts.append(str(dtype).split(".")[-1]) + self.name = "_".join(parts) + + @classmethod + def get_test_configs(cls) -> List["ConvStringPaddingTest"]: + # Patch embedding: non-overlapping 16x16 patches. + patches = { + "padding": "valid", + "kernel_size": 16, + "stride": 16, + "in_channels": 3, + "out_channels": 16, + "input_size": 64, + } + return [ + cls(**patches), + cls(padding="valid", dilation=2, groups=2), + cls(padding="same", kernel_size=3), + cls(padding="same", kernel_size=4), + cls(padding="same", kernel_size=(4, 3)), + cls(padding="same", kernel_size=4, dilation=2), + cls(padding="same", kernel_size=4, dilation=3), + cls(ndim=1, padding="valid", kernel_size=5, stride=2), + cls(ndim=1, padding="same", kernel_size=4), + cls(ndim=3, padding="valid", input_size=6), + cls(ndim=3, padding="same", kernel_size=2, input_size=6), + cls(**patches, dtype=torch.bfloat16), + cls(padding="same", kernel_size=4, dtype=torch.bfloat16), + ] + + def create_model(self) -> nn.Module: + conv = (nn.Conv1d, nn.Conv2d, nn.Conv3d)[self.ndim - 1] + model = conv( + self.in_channels, + self.out_channels, + self.kernel_size, + stride=self.stride, + padding=self.padding, + dilation=self.dilation, + groups=self.groups, + ) + return model.to(self.dtype) + + def create_inputs(self) -> Tuple[torch.Tensor, ...]: + shape = (2, self.in_channels) + (self.input_size,) * self.ndim + return (torch.randn(shape, dtype=self.dtype),) + + class ConvTranspose1dModel(nn.Module): """Simple model using ConvTranspose1d.""" @@ -4454,8 +4558,12 @@ def create_inputs(self) -> Tuple[torch.Tensor, ...]: class IndexModel(nn.Module): """Model that indexes a tensor using another tensor.""" + def __init__(self, dim: int = 0): + super().__init__() + self.dim = dim + def forward(self, x: torch.Tensor, indices: torch.Tensor) -> torch.Tensor: - return x[indices] + return x[(slice(None),) * self.dim + (indices,)] @register_test @@ -4468,26 +4576,35 @@ class IndexTest(OpTestCase): def __init__( self, - table_size: int = 100, - num_indices: int = 10, + table_shape: Tuple[int, ...] = (100,), + index_shape: Tuple[int, ...] = (10,), + dim: int = 0, ): - self.table_size = table_size - self.num_indices = num_indices - self.name = f"index_{table_size}_idx{num_indices}" + self.table_shape = table_shape + self.index_shape = index_shape + self.dim = dim + self.name = ( + f"index_{'x'.join(map(str, table_shape))}" + f"_idx{'x'.join(map(str, index_shape))}{f'_dim{dim}' if dim else ''}" + ) @classmethod def get_test_configs(cls) -> List["IndexTest"]: return [ - cls(table_size=100, num_indices=10), - cls(table_size=50, num_indices=5), + cls(table_shape=(100,), index_shape=(10,)), + cls(table_shape=(50,), index_shape=(5,)), + # Each index picks a whole row, unlike gather on the same shapes. + cls(table_shape=(5, 3), index_shape=(2, 3)), + # One index tensor on a later axis, as in x[:, i]. + cls(table_shape=(3, 5), index_shape=(3, 2), dim=1), ] def create_model(self) -> nn.Module: - return IndexModel() + return IndexModel(self.dim) def create_inputs(self) -> Tuple[torch.Tensor, ...]: - x = torch.randn(self.table_size) - indices = torch.randint(0, self.table_size, (self.num_indices,)) + x = torch.randn(self.table_shape) + indices = torch.randint(0, self.table_shape[self.dim], self.index_shape) return (x, indices) @@ -4699,6 +4816,234 @@ def create_inputs(self) -> Tuple[torch.Tensor, ...]: ) +class GatherModel(nn.Module): + def __init__(self, dim: int): + super().__init__() + self.dim = dim + + def forward(self, x: torch.Tensor, index: torch.Tensor) -> torch.Tensor: + return torch.gather(x, self.dim, index) + + +@register_test +class GatherTest(OpTestCase): + """aten.gather, including an index smaller than the input on another dim.""" + + name = "gather" + + def __init__( + self, shape, dim, index_shape, dtype=torch.float32, slices=0, dynamic=False + ): + self.shape = shape + self.dim = dim + self.index_shape = index_shape + self.dtype = dtype + self.dynamic = dynamic + self.expected_node_counts = {"TakeAlongAxisNode": 1, "SliceNode": slices} + self.name = ( + f"gather_{'x'.join(map(str, shape))}_dim{dim}" + f"_idx{'x'.join(map(str, index_shape))}_{str(dtype).split('.')[-1]}" + f"{'_dynamic' if dynamic else ''}" + ) + + @classmethod + def get_test_configs(cls) -> List["GatherTest"]: + return [ + cls((4, 8), 1, (4, 3)), + cls((4, 8), 0, (2, 8)), + cls((2, 3, 5), -1, (2, 3, 2), torch.bfloat16), + cls((4, 8), 0, (2, 3), torch.int32, slices=1), + cls((4, 5, 6), 1, (2, 3, 4), slices=2), + cls((6, 8), 1, (1, 2), torch.int64, slices=1), + # A dynamic input size is not known to match the index size. + cls((6, 8), 1, (3, 2), slices=1, dynamic=True), + ] + + def create_model(self) -> nn.Module: + return GatherModel(self.dim) + + def _inputs(self, shape): + x = (torch.randn(shape) * 100).to(self.dtype) + index = torch.randint(0, shape[self.dim], self.index_shape) + return (x, index) + + def create_inputs(self) -> Tuple[torch.Tensor, ...]: + return self._inputs(self.shape) + + def create_test_inputs(self) -> Tuple[torch.Tensor, ...]: + if not self.dynamic: + return self.create_inputs() + return self._inputs((4, *self.shape[1:])) + + def get_dynamic_shapes(self) -> Optional[Dict]: + if not self.dynamic: + return None + return {"x": {0: Dim("rows", min=3, max=8)}, "index": None} + + +class IndexPutModel(nn.Module): + def __init__(self, axes: Tuple[int, ...], accumulate: bool): + super().__init__() + self.axes = axes + self.accumulate = accumulate + + def forward(self, x, values, *indices): + index_list = [None] * (max(self.axes) + 1) + for axis, index in zip(self.axes, indices): + index_list[axis] = index + return torch.ops.aten.index_put.default(x, index_list, values, self.accumulate) + + +@register_test +class IndexPutTest(OpTestCase): + """aten.index_put with tensor indices, plain and with accumulate. + + Plain index_put leaves the winner of a repeated position undefined, so + plain configs index each position once. Accumulate configs repeat + positions on purpose, since those must add up. + """ + + name = "index_put" + + def __init__( + self, + shape, + indices, + values_shape, + accumulate=False, + dtype=torch.float32, + dynamic=False, + nodes=None, + ): + self.shape = shape + self.indices = indices + self.values_shape = values_shape + self.accumulate = accumulate + self.dtype = dtype + self.dynamic = dynamic + self.expected_node_counts = {"ScatterNode": 1, **(nodes or {})} + self.name = ( + f"index_put_{'x'.join(map(str, shape))}_axes" + f"{''.join(map(str, indices))}_acc{int(accumulate)}" + f"_{str(dtype).split('.')[-1]}{'_dynamic' if dynamic else ''}" + ) + + @classmethod + def get_test_configs(cls) -> List["IndexPutTest"]: + rows, cols = torch.tensor([[0], [2], [3]]), torch.tensor([[1, 4, 0, 5]]) + return [ + # The MoE dispatch: slots[expert, slot] = x[token]. + cls( + (4, 6, 8), + {0: torch.tensor([3, 0, 1, 3, 2]), 1: torch.tensor([0, 2, 5, 1, 4])}, + (5, 8), + dtype=torch.bfloat16, + ), + # The MoE combine: out.index_add_(0, token, y), with repeats. + cls( + (6, 8), + {0: torch.tensor([1, 4, 1, 0, 4, 1])}, + (6, 8), + True, + torch.float16, + ), + cls((6, 8), {0: torch.tensor([5, 0, 5, 2])}, (4, 8), True, torch.int32), + # A scalar value and negative indices. + cls( + (4, 6), + {0: torch.tensor([-1, 0, 2])}, + (), + nodes={"BroadcastToNode": 1}, + ), + cls((6,), {0: torch.tensor([4, 1])}, (2,), dtype=torch.bool), + # A leading full slice: the index dims move to the front. + cls( + (2, 6, 8), + {1: torch.tensor([3, 0, 3])}, + (3, 8), + True, + torch.bfloat16, + nodes={"BroadcastToNode": 1, "TransposeNode": 1}, + ), + cls( + (2, 5, 6, 3), + {1: torch.tensor([[0, 1], [2, 3]]), 2: torch.tensor([[5, 0], [1, 4]])}, + (2, 2, 2, 3), + nodes={"TransposeNode": 1}, + ), + # A 0-d index after a full slice adds no index dims to move. + cls( + (2, 6, 4), + {1: torch.tensor(3)}, + (2, 4), + nodes={"TransposeNode": 0}, + ), + # Separated indexed axes put the index dims in front already. + cls( + (2, 4, 3, 5), + { + 1: torch.tensor([1, 3], dtype=torch.int32), + 3: torch.tensor([4, 0], dtype=torch.int32), + }, + (2, 2, 3), + nodes={"TransposeNode": 0}, + ), + # Broadcast indices, and values broadcast along the leading dim. + cls( + (5, 6, 2), + {0: rows, 1: cols}, + (4, 2), + nodes={"BroadcastToNode": 1}, + ), + # Values broadcast along their own size-1 dim, as in x[i] = v[:, None]. + cls( + (6, 8), + {0: torch.tensor([5, 0, 2])}, + (3, 1), + nodes={"BroadcastToNode": 1}, + ), + cls( + (8, 4), + {0: torch.tensor([6, 1, 1, 3, 6])}, + (5, 4), + True, + dynamic=True, + nodes={"BroadcastToNode": 0, "SymSizeNode": 0}, + ), + ] + + def create_model(self) -> nn.Module: + return IndexPutModel(tuple(self.indices), self.accumulate) + + def _inputs(self, indices): + values_shape = self.values_shape + if self.dynamic: + n = next(iter(indices.values())).shape[0] + values_shape = (n, *values_shape[1:]) + if self.dtype == torch.bool: + # Every write flips a value, so a dropped write cannot match. + x, values = torch.zeros(self.shape), torch.ones(values_shape) + else: + # Small integers add up exactly in any order, even in half precision. + x = torch.randint(-8, 8, self.shape) + values = torch.randint(-8, 8, values_shape) + return (x.to(self.dtype), values.to(self.dtype), *indices.values()) + + def create_inputs(self) -> Tuple[torch.Tensor, ...]: + return self._inputs(self.indices) + + def create_test_inputs(self) -> Tuple[torch.Tensor, ...]: + if not self.dynamic: + return self.create_inputs() + return self._inputs({0: torch.tensor([2, 7, 2])}) + + def get_dynamic_shapes(self) -> Optional[Dict]: + if not self.dynamic: + return None + n = Dim("n", min=2, max=16) + return {"x": None, "values": {0: n}, "indices": ({0: n},)} + + class IndexUpdateModel(nn.Module): """Model that performs index_copy on a mutable buffer. diff --git a/backends/mlx/test/test_partitioner.py b/backends/mlx/test/test_partitioner.py index 32720e76a1e..a8bc24d6489 100644 --- a/backends/mlx/test/test_partitioner.py +++ b/backends/mlx/test/test_partitioner.py @@ -66,6 +66,15 @@ def _delegate_count(program) -> int: ) +def _cpu_ops(program) -> set: + """The ATen ops left outside every delegate, which run on the CPU.""" + return { + str(node.target) + for node in program.exported_program().graph_module.graph.nodes + if node.op == "call_function" and str(node.target).startswith("aten.") + } + + def _run(model, inputs, transform_passes=None): """Lower, execute, and return the node counts, the delegate count and the error. @@ -185,6 +194,195 @@ def forward(self, base, index, src): self.assertEqual(err, 0) +class MoeDispatch(nn.Module): + """The static dispatch of a mixture-of-experts block. + + Routing weights come from gather, an int64 histogram counts tokens per + expert, tokens are scattered into expert slots, and index_add_ (which + becomes index_put with accumulate) brings the results back. + """ + + def __init__(self, experts=4, hidden=8, top_k=2): + super().__init__() + self.top_k = top_k + self.router = nn.Linear(hidden, experts, bias=False) + self.weight = nn.Parameter(torch.randn(experts, hidden, hidden)) + + def forward(self, x): + tokens, hidden = x.shape + experts = self.weight.shape[0] + scores = self.router(x).softmax(-1) + _, selected = torch.topk(scores, self.top_k, dim=-1) + weights = scores.gather(1, selected) + expert = selected.reshape(-1) + order = torch.argsort(expert) + token = torch.arange(tokens).repeat_interleave(self.top_k)[order] + expert = expert[order] + counts = torch.zeros(experts, dtype=torch.long).scatter_add( + 0, expert, torch.ones_like(expert) + ) + slot = torch.arange(tokens * self.top_k) - (counts.cumsum(0) - counts)[expert] + slots = torch.zeros(experts, tokens, hidden) + slots[expert, slot] = x[token] + y = torch.bmm(slots, self.weight)[expert, slot] + out = torch.zeros(tokens, hidden) + out.index_add_(0, token, weights.reshape(-1, 1)[order] * y) + return x + out + + +class TestMLXPartitionerIndexPutAndGather(unittest.TestCase): + def test_moe_dispatch_is_lowered(self): + model, inputs = MoeDispatch().eval(), (torch.randn(6, 8),) + program = _lower(model, inputs) + # Only the int64 histogram stays on the CPU, between two delegates. + self.assertEqual(_cpu_ops(program), {"aten.scatter_add.out"}) + self.assertEqual(_delegate_count(program), 2) + method = Runtime.get().load_program(program.buffer).load_method("forward") + err = (method.execute(list(inputs))[0] - model(*inputs)).abs().max() + self.assertLess(err.item(), 1e-5) + + def test_cache_write_into_a_buffer(self): + class Cache(nn.Module): + def __init__(self): + super().__init__() + self.register_buffer("k", torch.zeros(2, 8, 4)) + + def forward(self, pos, v): + self.k[:, pos] = v + return self.k * 1 + + program = _lower(Cache().eval(), (torch.tensor([0]), torch.randn(2, 1, 4))) + self.assertEqual(_cpu_ops(program), set()) + method = Runtime.get().load_program(program.buffer).load_method("forward") + reference = Cache() + # Each call must see the writes of the calls before it. + for pos in (0, 3, 5): + inputs = (torch.tensor([pos]), torch.randn(2, 1, 4)) + out = method.execute(list(inputs))[0] + self.assertTrue(torch.equal(out, reference(*inputs))) + # Eager raises on a position outside the cache; the delegate clamps it + # to the nearest end, so the write stays inside the buffer. + for pos, clamped in ((8, 7), (-9, 0)): + v = torch.randn(2, 1, 4) + out = method.execute([torch.tensor([pos]), v])[0] + self.assertTrue(torch.equal(out, reference(torch.tensor([clamped]), v))) + + def test_gather_clamps_out_of_range_indices(self): + class Gather(nn.Module): + def forward(self, x, index): + return torch.gather(x, 1, index) + + x = torch.arange(12.0).reshape(3, 4) + program = _lower(Gather(), (x, torch.zeros(3, 2, dtype=torch.long))) + method = Runtime.get().load_program(program.buffer).load_method("forward") + # Eager raises; the delegate clamps to the nearest end instead of + # reading outside x. + out = method.execute([x, torch.tensor([[9, 0], [-7, 1], [3, 2]])])[0] + clamped = torch.tensor([[3, 0], [0, 1], [3, 2]]) + self.assertTrue(torch.equal(out, torch.gather(x, 1, clamped))) + + def test_empty_axis(self): + # The delegate cannot return an empty tensor yet, so both models sum. + class IndexPut(nn.Module): + def forward(self, x, index, values): + return torch.ops.aten.index_put.default(x, [index], values).sum(0) + + class Gather(nn.Module): + def forward(self, x, index): + return torch.gather(x, 1, index).sum(1) + + def run(model, *inputs): + program = _lower(model, inputs) + self.assertEqual(_cpu_ops(program), set()) + method = Runtime.get().load_program(program.buffer).load_method("forward") + return method.execute(list(inputs))[0] + + # Writing or reading nothing on an empty axis works, as in eager. + empty, no_index = torch.zeros(0, 4), torch.zeros(0, dtype=torch.long) + out = run(IndexPut(), empty, no_index, empty) + self.assertTrue(torch.equal(out, torch.zeros(4))) + out = run(Gather(), torch.zeros(3, 0), torch.zeros(3, 0, dtype=torch.long)) + self.assertTrue(torch.equal(out, torch.zeros(3))) + # Eager raises; with no element to clamp to, the delegate must too. + with self.assertRaisesRegex(RuntimeError, "empty axis"): + run(Gather(), torch.zeros(3, 0), torch.zeros(3, 2, dtype=torch.long)) + + def test_unsupported_forms_run_on_the_cpu(self): + class IndexPut(nn.Module): + def __init__(self, accumulate=False): + super().__init__() + self.accumulate = accumulate + + def forward(self, x, index, values): + # Some work before the refused op, so the program has a delegate. + x = x.logical_not() if x.dtype == torch.bool else x + 1 + return torch.ops.aten.index_put.default( + x, [index], values, self.accumulate + ) + + class Gather(nn.Module): + def forward(self, x, index): + return torch.gather(x + 1, 0, index) + + index = torch.tensor([4, 1, 4]) + cases = { + "int64": ( + IndexPut(), + (torch.zeros(6, dtype=torch.long), index[1:], torch.tensor([7, 9])), + "on torch.int64 is not supported", + ), + "bool_accumulate": ( + IndexPut(True), + ( + torch.ones(6, dtype=torch.bool), + index, + torch.ones(3, dtype=torch.bool), + ), + "accumulate on bool", + ), + "bool_mask": ( + IndexPut(), + ( + torch.zeros(6), + torch.tensor([1, 0, 1, 0, 0, 1]) == 1, + torch.tensor(2.0), + ), + "only int32 and int64 index tensors", + ), + # PyTorch lets a 0-d index gather from a 1-d input. + "gather_0d_index": ( + Gather(), + (torch.arange(5.0), torch.tensor(2)), + "index of rank 0", + ), + } + refused_ops = (torch.ops.aten.index_put.default, torch.ops.aten.gather.default) + for name, (model, inputs, reason) in cases.items(): + with self.subTest(name): + # Refused on purpose, not by a handler that crashed. + builder = MLXProgramBuilder(get_fake_program(export(model, inputs))) + builder.check_support_only() + [info] = [ + info + for node, info in builder.node_info.items() + if node.target in refused_ops + ] + self.assertFalse(info.supported) + self.assertIn(reason, info.unsupported_reason) + + program = _lower(model, inputs) + self.assertEqual(_delegate_count(program), 1) + op = "gather" if isinstance(model, Gather) else "index_put" + self.assertEqual(_cpu_ops(program), {f"aten.{op}.out"}) + method = ( + Runtime.get().load_program(program.buffer).load_method("forward") + ) + # Exact, and bool outputs cannot be subtracted. + self.assertTrue( + torch.equal(method.execute(list(inputs))[0], model(*inputs)) + ) + + class Sdpa(nn.Module): def __init__(self, is_causal: bool = False): super().__init__() diff --git a/backends/mlx/test/test_run_all_tests.py b/backends/mlx/test/test_run_all_tests.py new file mode 100644 index 00000000000..ad4366a0975 --- /dev/null +++ b/backends/mlx/test/test_run_all_tests.py @@ -0,0 +1,41 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Tests for the MLX op test harness itself.""" + +import tempfile +import unittest +from pathlib import Path + +import torch +from executorch.backends.mlx.test.run_all_tests import _run_test_in_subprocess +from executorch.backends.mlx.test.test_utils import ( + clean_test_outputs, + print_mlx_graph_summary, +) +from executorch.exir import to_edge + + +class RunAllTestsTest(unittest.TestCase): + def test_isolated_run_reports_result(self): + self.addCleanup(clean_test_outputs, ["sub_10"]) + self.assertTrue(_run_test_in_subprocess("sub_10")) + self.assertFalse(_run_test_in_subprocess("no_such_config")) + + def test_summary_of_program_without_mlx_delegate_returns(self): + program = to_edge( + torch.export.export(torch.nn.ReLU(), (torch.randn(4),)) + ).to_executorch() + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "model.pte" + path.write_bytes(program.buffer) + # A test that lowers to no MLX delegate fails on its delegate count + # check after this, so printing must not end the process. + print_mlx_graph_summary(path) + + +if __name__ == "__main__": + unittest.main() diff --git a/backends/mlx/test/test_utils.py b/backends/mlx/test/test_utils.py index adf4e56e3a2..ddcfb3cf52e 100644 --- a/backends/mlx/test/test_utils.py +++ b/backends/mlx/test/test_utils.py @@ -1182,3 +1182,28 @@ def run_op_test_main( elif args.action == "run": passed = test.run_test(verbose=args.verbose) sys.exit(0 if passed else 1) + + +if __name__ == "__main__": + # run_all_tests --isolate runs each config through this entry point. Tests + # register in the imported module, not in this __main__ copy of it. + import argparse + import sys + + from executorch.backends.mlx.test import test_utils + from executorch.backends.mlx.test.run_all_tests import discover_and_import_tests + + parser = argparse.ArgumentParser(description="Run one MLX op test config") + parser.add_argument("config_name") + parser.add_argument("--verbose", action="store_true") + parser.add_argument("--timeout", type=int, default=DEFAULT_TEST_TIMEOUT) + args = parser.parse_args() + + discover_and_import_tests() + # First match, like the -j path, since a few config names repeat. + tests = [ + t for name, t in test_utils.get_all_test_configs() if name == args.config_name + ] + if not tests: + sys.exit(f"Could not find test config: {args.config_name}") + sys.exit(0 if tests[0].run_test(verbose=args.verbose, timeout=args.timeout) else 1) From a0d6766950b3aa49e15db7dae5290ee92951af80 Mon Sep 17 00:00:00 2001 From: mcremon-meta <134334895+mcremon-meta@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:58:50 -0800 Subject: [PATCH 066/128] Move backend-neutral constant helpers to backends/transforms/utils Summary: Adds graph helpers that have nothing to do with quantization to `backends/transforms/utils.py`, so any backend's passes can use them: - `get_constant`, `get_input_kind`, `get_fqn`: read the constant backing a lifted placeholder. - `add_constant`: add a new lifted constant ahead of the user inputs, with an export-style node name and fqn. - `compute_meta_val`: recompute `meta["val"]` for a new or mutated node under the graph's fake mode. `_get_fake_tensor_mode` now shares `_fake_mode_from_node` with `add_constant`. The helpers are tested in `backends/transforms/test/test_transforms_utils.py`. `transforms:utils` becomes visible to `EXECUTORCH_CLIENTS`, and so does `transforms:permute_pass_utils`, so clients can use its `get_arg` / `set_arg`. Reviewed By: rascani Differential Revision: D129173476 Pull Request resolved: https://github.com/pytorch/executorch/pull/24620 --- backends/transforms/targets.bzl | 14 + .../transforms/test/test_transforms_utils.py | 262 ++++++++++++++++++ backends/transforms/utils.py | 197 ++++++++++++- 3 files changed, 463 insertions(+), 10 deletions(-) create mode 100644 backends/transforms/test/test_transforms_utils.py diff --git a/backends/transforms/targets.bzl b/backends/transforms/targets.bzl index c10cef9d060..37aa83a0985 100644 --- a/backends/transforms/targets.bzl +++ b/backends/transforms/targets.bzl @@ -82,6 +82,18 @@ def define_common_targets(): ], ) + runtime.python_test( + name = "test_transforms_utils", + srcs = ["test/test_transforms_utils.py"], + deps = [ + ":utils", + "//caffe2:torch", + "//executorch/backends/test:graph_builder", + "//executorch/backends/test:program_builder", + "//executorch/exir/dialects:lib", + ], + ) + runtime.python_test( name = "test_fuse_rms_norm", srcs = ["test/test_fuse_rms_norm.py"], @@ -269,6 +281,7 @@ def define_common_targets(): srcs = ["utils.py"], visibility = [ "//executorch/backends/...", + "@EXECUTORCH_CLIENTS", ], deps = [ "//caffe2:torch", @@ -673,6 +686,7 @@ def define_common_targets(): srcs = ["permute_pass_utils.py"], visibility = [ "//executorch/backends/...", + "@EXECUTORCH_CLIENTS", ], deps = [ ":channels_last_layout", diff --git a/backends/transforms/test/test_transforms_utils.py b/backends/transforms/test/test_transforms_utils.py new file mode 100644 index 00000000000..08ec864ead5 --- /dev/null +++ b/backends/transforms/test/test_transforms_utils.py @@ -0,0 +1,262 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + + +import unittest + +import torch +from executorch.backends.test.graph_builder import GraphBuilder +from executorch.backends.test.program_builder import ProgramBuilder +from executorch.backends.transforms.utils import ( + add_constant, + compute_meta_val, + get_constant, +) +from executorch.exir.dialects._ops import ops as exir_ops +from torch._subclasses.fake_tensor import FakeTensor +from torch.export import ExportedProgram +from torch.export.graph_signature import ( + ExportGraphSignature, + InputKind, + InputSpec, + TensorArgument, +) + + +def _build_ep_with_divergent_const( + node_name: str, fqn: str, value: torch.Tensor +) -> ExportedProgram: + """Build an ExportedProgram with a lifted constant whose node name != fqn. + + This mimics the state export produces for a fusion-created constant: the + placeholder node carries a ``c_``-prefixed name (e.g. ``c__scale_16``) while + its fqn into ``ep.constants`` is un-prefixed (``_scale_16``). Crucially the + fqn is NOT itself a graph node name, so a later ``graph.placeholder`` can + generate a node named exactly that fqn. + """ + builder = ProgramBuilder() + x = builder.placeholder("x", torch.randn(value.shape)) + c = builder.placeholder(node_name, value, input_kind=InputKind.CONSTANT_TENSOR) + out = builder.call_operator(op=exir_ops.edge.aten.add.Tensor, args=(x, c)) + builder.output([out]) + ep = builder.get_program() + + # Rewrite the constant's spec so its target (fqn) diverges from the node name, + # and move its backing value under the new fqn. + sig = ep.graph_signature + new_specs = [ + ( + InputSpec( + kind=s.kind, + arg=TensorArgument(name=node_name), + target=fqn, + persistent=True, + ) + if getattr(s.arg, "name", None) == node_name + else s + ) + for s in sig.input_specs + ] + ep._graph_signature = ExportGraphSignature( + input_specs=new_specs, output_specs=list(sig.output_specs) + ) + ep.constants[fqn] = ep.constants.pop(node_name) + return ep + + +class AddConstantTest(unittest.TestCase): + """Regression tests for add_constant's fqn uniquification. + + add_constant uniquifies the placeholder *node name* against the graph, but + the spec target (the fqn into ep.constants) lives in a separate namespace: + export gives lifted constants a ``c_``-prefixed node name over an un-prefixed + fqn. A node name generated here can therefore equal an existing constant's + fqn, so the derived target must be uniquified against the fqn namespace -- + otherwise two specs share one ep.constants entry and constant_prop, erasing + one dead placeholder, pops the value the other still needs (a KeyError in + get_lifted_tensor_constant, seen via QuantAbsorptionPass minting many + _scale/_zero_point constants until one collides with a fusion fqn). + """ + + def _find_node(self, ep: ExportedProgram, name: str) -> torch.fx.Node: + return next(n for n in ep.graph.nodes if n.name == name) + + def _spec_for(self, ep: ExportedProgram, node_name: str) -> InputSpec: + return next( + s + for s in ep.graph_signature.input_specs + if getattr(s.arg, "name", None) == node_name + ) + + def test_add_constant_uniquifies_target_against_existing_fqn(self) -> None: + orig_val = torch.tensor([1.0, 2.0, 3.0]) + ep = _build_ep_with_divergent_const("c_s", "s", orig_val) + # The fqn "s" already exists, so add_constant must not reuse it. + self.assertIn( + "s", ep.graph_signature.inputs_to_lifted_tensor_constants.values() + ) + + anchor = self._find_node(ep, "c_s") + new_val = torch.tensor([9.0, 9.0, 9.0]) + new_node = add_constant(ep, "s", new_val, anchor, InputKind.CONSTANT_TENSOR) + + # The node name is export-style prefixed for its kind (c_ for a constant), + # keeping the new placeholder in the same namespace as export's own. + self.assertTrue(new_node.name.startswith("c_")) + + # The fqn (spec target) was uniquified away from the existing "s". + new_spec = self._spec_for(ep, new_node.name) + self.assertNotEqual(new_spec.target, "s") + + # No two input specs share a target (fqn), so no shared ep.constants entry. + targets = [ + s.target for s in ep.graph_signature.input_specs if s.target is not None + ] + self.assertEqual(len(targets), len(set(targets))) + + # The original constant's value was not overwritten, and both round-trip. + orig_after = get_constant(ep, self._find_node(ep, "c_s")) + assert orig_after is not None + self.assertTrue(torch.equal(orig_after, orig_val)) + new_after = get_constant(ep, new_node) + assert new_after is not None + self.assertTrue(torch.equal(new_after, new_val)) + + def test_add_constant_prefixes_node_name_by_kind(self) -> None: + """add_constant owns the prefixing: node = , fqn bare.""" + ep = _build_ep_with_divergent_const("c_seed", "seed", torch.tensor([0.0])) + anchor = self._find_node(ep, "c_seed") + + # A bare logical name gets the kind's prefix on the node, bare fqn. + node = add_constant( + ep, "myscale", torch.tensor(0.5), anchor, InputKind.CONSTANT_TENSOR + ) + self.assertEqual(node.name, "c_myscale") + self.assertEqual(self._spec_for(ep, node.name).target, "myscale") + + # Passing a name that already carries the kind's prefix is a caller bug. + with self.assertRaises(AssertionError): + add_constant( + ep, "c_other", torch.tensor(0.25), anchor, InputKind.CONSTANT_TENSOR + ) + + def test_repeated_add_constant_never_collides(self) -> None: + """Minting many constants with the same base name keeps every fqn unique. + + Mirrors QuantAbsorptionPass adding a fresh _scale/_zero_point per absorbed + op: each must land on its own ep.constants entry, and none may clobber the + pre-existing constant that shares the base name. + """ + orig_val = torch.tensor([1.0, 2.0, 3.0]) + ep = _build_ep_with_divergent_const("c_s", "s", orig_val) + anchor = self._find_node(ep, "c_s") + + added = [] + for i in range(20): + val = torch.tensor([float(i)]) + node = add_constant(ep, "s", val, anchor, InputKind.CONSTANT_TENSOR) + added.append((node, val)) + + targets = [ + s.target for s in ep.graph_signature.input_specs if s.target is not None + ] + self.assertEqual(len(targets), len(set(targets)), "duplicate fqn targets") + + # The pre-existing constant survived every mint. + orig_after = get_constant(ep, self._find_node(ep, "c_s")) + assert orig_after is not None + self.assertTrue(torch.equal(orig_after, orig_val)) + + for node, val in added: + got = get_constant(ep, node) + assert got is not None + self.assertTrue(torch.equal(got, val)) + + def test_add_constant_into_graph_without_placeholders(self) -> None: + class NoInputs(torch.nn.Module): + def forward(self) -> torch.Tensor: + return torch.ones(2) + + ep = torch.export.export(NoInputs(), ()) + self.assertFalse(ep.graph.find_nodes(op="placeholder")) + ones = next(n for n in ep.graph.nodes if n.op == "call_function") + + val = torch.tensor([0.5]) + node = add_constant(ep, "s", val, ones, InputKind.CONSTANT_TENSOR) + + self.assertEqual(ep.graph.find_nodes(op="placeholder"), [node]) + got = get_constant(ep, node) + assert got is not None + self.assertTrue(torch.equal(got, val)) + + +class ComputeMetaValTest(unittest.TestCase): + """compute_meta_val recomputes a node's meta['val'] by executing it under the + graph's fake mode. Verified against the val GraphBuilder itself computed, for + single-output, multi-output, and literal-only (aten.full) nodes.""" + + def _node(self, gm: torch.fx.GraphModule, target: object) -> torch.fx.Node: + return next( + n for n in gm.graph.nodes if n.op == "call_function" and n.target == target + ) + + def test_single_output_from_fake_inputs(self) -> None: + builder = GraphBuilder() + x = builder.placeholder("x", torch.randn(2, 3)) + y = builder.call_operator(op=torch.ops.aten.add.Tensor, args=(x, x)) + builder.output([y]) + gm = builder.get_graph_module() + + add_node = self._node(gm, torch.ops.aten.add.Tensor) + expected = add_node.meta["val"] + got = compute_meta_val(add_node) + + self.assertIsInstance(got, FakeTensor) + self.assertEqual(tuple(got.shape), tuple(expected.shape)) + self.assertEqual(got.dtype, expected.dtype) + + def test_multi_output_returns_tuple_of_fakes(self) -> None: + builder = GraphBuilder() + x = builder.placeholder("x", torch.randn(2, 4)) + w = builder.placeholder("w", torch.randn(4)) + b = builder.placeholder("b", torch.randn(4)) + ln = builder.call_operator( + op=torch.ops.aten.native_layer_norm.default, + args=(x, [4], w, b, 1e-5), + ) + builder.output([builder.call_getitem(ln, 0)]) + gm = builder.get_graph_module() + + ln_node = self._node(gm, torch.ops.aten.native_layer_norm.default) + got = compute_meta_val(ln_node) + + self.assertIsInstance(got, (tuple, list)) + expected = ln_node.meta["val"] + self.assertEqual(len(got), len(expected)) + for g, e in zip(got, expected): + self.assertIsInstance(g, FakeTensor) + self.assertEqual(tuple(g.shape), tuple(e.shape)) + + def test_literal_only_full_is_faked_under_mode(self) -> None: + """aten.full has no tensor inputs, so it yields a fake (not a real) tensor + only because compute_meta_val runs it under the graph's fake mode -- the + case the simpler implementation hinges on.""" + builder = GraphBuilder() + x = builder.placeholder("x", torch.randn(2)) + full = builder.call_operator( + op=torch.ops.aten.full.default, + args=([4], 0), + kwargs={"dtype": torch.int32}, + ) + builder.output([x, full]) + gm = builder.get_graph_module() + + full_node = self._node(gm, torch.ops.aten.full.default) + got = compute_meta_val(full_node) + + self.assertIsInstance(got, FakeTensor) + self.assertEqual(tuple(got.shape), (4,)) + self.assertEqual(got.dtype, torch.int32) diff --git a/backends/transforms/utils.py b/backends/transforms/utils.py index 314dd8100d5..732db29486d 100644 --- a/backends/transforms/utils.py +++ b/backends/transforms/utils.py @@ -10,6 +10,7 @@ import torch from executorch.exir import ExportedProgram from torch._export.utils import ( + _detect_fake_mode_from_gm, get_buffer, get_lifted_tensor_constant, get_param, @@ -17,7 +18,11 @@ is_lifted_tensor_constant, is_param, ) -from torch._subclasses.fake_tensor import FakeTensorConverter +from torch._subclasses.fake_tensor import ( + FakeTensor, + FakeTensorConverter, + FakeTensorMode, +) from torch.export.graph_signature import ( ExportGraphSignature, InputKind, @@ -26,6 +31,16 @@ OutputSpec, TensorArgument, ) +from torch.fx import map_arg + + +def _fake_mode_from_node(node: torch.fx.Node) -> FakeTensorMode: + """Pull the graph's FakeTensorMode off a node's meta['val'], which is a + FakeTensor for single-output nodes and a tuple/list of them otherwise.""" + val = node.meta["val"] + if isinstance(val, (tuple, list)): + val = next(v for v in val if isinstance(v, FakeTensor)) + return val.fake_mode def _get_fake_tensor_mode(graph: torch.fx.Graph, data: torch.Tensor) -> torch.Tensor: @@ -48,15 +63,9 @@ def _get_fake_tensor_mode(graph: torch.fx.Graph, data: torch.Tensor) -> torch.Te "Cannot create fake tensor: graph has no nodes to extract fake_mode from" ) - example_node = nodes[0] - if isinstance( - example_node.meta["val"], (tuple, torch.fx.immutable_collections.immutable_list) - ): - example_fake_tensor = example_node.meta["val"][0] - else: - example_fake_tensor = example_node.meta["val"] - - return FakeTensorConverter().from_real_tensor(example_fake_tensor.fake_mode, t=data) + return FakeTensorConverter().from_real_tensor( + _fake_mode_from_node(nodes[0]), t=data + ) def reserve_node_names(graph_module: torch.fx.GraphModule) -> None: @@ -108,6 +117,73 @@ def get_param_tensor( raise RuntimeError(f"unsupported param type, {node.op}.") +def get_constant(ep: ExportedProgram, node: torch.fx.Node) -> Optional[torch.Tensor]: + """Return the tensor backing a lifted constant placeholder, or None. + + Unlike get_param_tensor, non-constant nodes yield None instead of raising, and + parameters are unwrapped to plain tensors. + """ + if node.op != "placeholder" or not is_param_node(ep, node): + return None + tensor = get_param_tensor(ep, node) + return tensor.data if isinstance(tensor, torch.nn.Parameter) else tensor + + +def get_input_kind(ep: ExportedProgram, node: torch.fx.Node) -> Optional[InputKind]: + """Return the InputKind of a constant placeholder node, or None.""" + if node.op != "placeholder": + return None + + sig = ep.graph_signature + if node.name in sig.inputs_to_parameters: + return InputKind.PARAMETER + if node.name in sig.inputs_to_buffers: + return InputKind.BUFFER + if node.name in sig.inputs_to_lifted_tensor_constants: + return InputKind.CONSTANT_TENSOR + return None + + +def get_fqn(ep: ExportedProgram, node: torch.fx.Node) -> Optional[str]: + """Return the fqn backing a constant placeholder, or None. + + The fqn is the key into ``ep.state_dict`` / ``ep.constants`` and the + ``InputSpec.target`` -- the un-prefixed logical name (node ``p_a_b`` -> fqn + ``a.b``), i.e. the inverse of export's ``c_``/``b_``/``p_`` node-name prefixing. + """ + if node.op != "placeholder": + return None + + sig = ep.graph_signature + for mapping in ( + sig.inputs_to_parameters, + sig.inputs_to_buffers, + sig.inputs_to_lifted_tensor_constants, + ): + if node.name in mapping: + return mapping[node.name] + return None + + +def compute_meta_val(node: torch.fx.Node) -> object: + """Compute ``meta['val']`` for a new or mutated call_function node by running + its target on its inputs' fake vals under the graph's fake mode. + + Handles single-output, multi-output, and literal-only ops (e.g. ``aten.full``, + which is faked only because it runs under the fake mode). + """ + gm = node.graph.owning_module + assert gm is not None, "node's graph has no owning module" + fake_mode = _detect_fake_mode_from_gm(gm) + assert fake_mode is not None, "graph has no fake tensor mode" + target = node.target + assert callable(target), f"expected a callable target, got {target}" + args = map_arg(node.args, lambda n: n.meta["val"]) + kwargs = map_arg(node.kwargs, lambda n: n.meta["val"]) + with fake_mode: + return target(*args, **kwargs) + + def set_param_tensor( exp_prog: ExportedProgram, node: torch.fx.Node, data: torch.Tensor ) -> None: @@ -246,6 +322,107 @@ def create_constant_placeholder( return node +# The node-name prefix export gives each lifted input kind. Mirroring it keeps +# add_constant's placeholders indistinguishable from export's own and makes +# node-name uniqueness imply fqn uniqueness (fqn == node name minus prefix). +_INPUT_KIND_PREFIX: dict[InputKind, str] = { + InputKind.PARAMETER: "p_", + InputKind.BUFFER: "b_", + InputKind.CONSTANT_TENSOR: "c_", +} + + +def add_constant( + ep: ExportedProgram, + name: str, + tensor: torch.Tensor, + before_node: torch.fx.Node, + kind: InputKind, +) -> torch.fx.Node: + """Add a new constant placeholder, placed ahead of the user inputs. + + Unlike create_constant_placeholder, the caller does not pick the insertion + point and every call mints a fresh constant (no sharing by name). + + ``name`` must be a logical (unprefixed) base -- add_constant owns the + prefixing. The node name is the export-style ```` (``c_`` + constant, ``b_`` buffer, ``p_`` parameter) and the fqn is that node name with + the prefix stripped, so fx's node-name uniquification also uniquifies the fqn + within a kind. The assert guards a cross-kind fqn clash (the same fqn backing + both ep.constants and ep.state_dict), which would otherwise surface later as a + KeyError in constant_prop's get_lifted_tensor_constant. + + ``before_node`` supplies the fake mode for the new placeholder's meta['val']. + """ + graph = ep.graph_module.graph + placeholders = graph.find_nodes(op="placeholder") + + # Placeholder order and input_specs order must stay in lockstep, with + # constants ahead of user inputs (create_constant_placeholder enforces this). + user_inputs = { + spec.arg.name + for spec in ep.graph_signature.input_specs + if spec.kind == InputKind.USER_INPUT and hasattr(spec.arg, "name") + } + insert_at = len(ep.graph_signature.input_specs) + anchor = None + for index, candidate in enumerate(placeholders): + if candidate.name in user_inputs: + anchor = candidate + insert_at = index + break + + assert ( + kind in _INPUT_KIND_PREFIX + ), f"add_constant supports {set(_INPUT_KIND_PREFIX)}, got {kind}" + prefix = _INPUT_KIND_PREFIX[kind] + assert not name.startswith( + prefix + ), f"add_constant expects a logical (unprefixed) name for {kind}, got {name!r}" + + if anchor is not None: + position = graph.inserting_before(anchor) + elif placeholders: + position = graph.inserting_after(placeholders[-1]) + else: + position = graph.inserting_before(next(iter(graph.nodes))) + with position: + placeholder = graph.placeholder(prefix + name) + placeholder.target = placeholder.name + fake_mode = _fake_mode_from_node(before_node) + placeholder.meta["val"] = fake_mode.from_tensor(tensor, static_shapes=True) + + fqn = placeholder.name[len(prefix) :] + assert ( + fqn not in ep.constants and fqn not in ep.state_dict + ), f"add_constant: fqn {fqn!r} already backed; prefix invariant violated" + + input_specs = list(ep.graph_signature.input_specs) + input_specs.insert( + insert_at, + InputSpec( + kind=kind, + arg=TensorArgument(name=placeholder.name), + target=fqn, + persistent=True, + ), + ) + ep._graph_signature = ExportGraphSignature( + input_specs=input_specs, + output_specs=list(ep.graph_signature.output_specs), + ) + + match kind: + case InputKind.PARAMETER: + ep.state_dict[fqn] = torch.nn.Parameter(tensor, requires_grad=False) + case InputKind.BUFFER: + ep.state_dict[fqn] = tensor + case _: + ep.constants[fqn] = tensor + + return placeholder + + def delete_constant_placeholder(exp_program: ExportedProgram, node: torch.fx.Node): """ Deletes a node of type parameter, buffer, or lifted constant tensor and its related From 5e8d11b75f4b2eb6205812e1bb306cbe9b2a59ec Mon Sep 17 00:00:00 2001 From: pssrawat <34485295+pssrawat@users.noreply.github.com> Date: Wed, 4 Nov 2026 16:19:46 -0500 Subject: [PATCH 067/128] Add CortexMQuantizer.set_node_target (#24398) Summary: `CortexMQuantizer` is a fixed recipe: convolutions use `INT8_PER_CHANNEL_CONFIG` and every other supported op `per_tensor_config`. This adds `set_node_target(op, config)`, which overrides the config of every node calling `op`, as the Arm quantizer's `set_node_target` does. Each call takes precedence over earlier calls and over the defaults: it inserts a `PatternQuantizer` ahead of the others, and the pattern matcher skips nodes an earlier quantizer has matched. The pattern matcher only matches consecutive nodes from its finder, so with a config the finder also finds the activations that `op`'s patterns fuse after it, as the default convolution quantizer's finder already does. Conv + relu, for example, still matches as one pattern under an override. An `op` that starts no supported pattern, such as an unsupported op or the wrong overload, would give such an override nothing to match, so `set_node_target` raises a `ValueError` instead of silently doing nothing. `None` leaves the nodes unquantized. The matcher marks each node found for a `None` config on its own, without consulting the supported patterns, so the finder only finds `op`: finding the fused activations as well would leave every relu, clamp or hardtanh in the model unquantized and break their fusion into other ops such as linear. This also lets `None` apply to any op, including activations that only appear fused, as in the Arm quantizer. This diff was authored with Claude Code. Differential Revision: D128731119 Pull Request resolved: https://github.com/pytorch/executorch/pull/24398 --- backends/cortex_m/quantizer/node_finders.py | 35 +++++++ backends/cortex_m/quantizer/quantizer.py | 45 ++++++++- .../cortex_m/test/misc/test_node_finders.py | 46 +++++++++ .../cortex_m/test/misc/test_quantization.py | 95 +++++++++++++++++++ 4 files changed, 217 insertions(+), 4 deletions(-) diff --git a/backends/cortex_m/quantizer/node_finders.py b/backends/cortex_m/quantizer/node_finders.py index 56a6c6c1585..15929cdeed4 100644 --- a/backends/cortex_m/quantizer/node_finders.py +++ b/backends/cortex_m/quantizer/node_finders.py @@ -104,6 +104,41 @@ def __repr__(self) -> str: return f"{self.__class__.__name__} targeting node targets: {format_items(target_names)}" +class NodeTargetActivationNodeFinder(NodeFinder): + """ + Finds all nodes matching the given target, and the nodes matching the given + activation(s) that are the sole user of one of them. + """ + + def __init__( + self, target: OpOverload, activations: OpOverload | List[OpOverload] + ) -> None: + super().__init__() + self.target = target + self.activations = make_list(activations) + + def find_nodes(self, model: GraphModule) -> Iterator[Node]: + return ( + n + for n in model.graph.nodes + if n.target == self.target + or ( + n.target in self.activations + and any( + i.target == self.target and len(i.users) == 1 + for i in n.all_input_nodes + ) + ) + ) + + def __repr__(self) -> str: + activation_names = [a._name for a in self.activations] + return ( + f"{self.__class__.__name__} targeting node target: {self.target._name} " + f"with activations: {format_items(activation_names)}" + ) + + class ModuleNameNodeFinder(NodeFinder): """ Finds all nodes in the module matching the given name(s). diff --git a/backends/cortex_m/quantizer/quantizer.py b/backends/cortex_m/quantizer/quantizer.py index 1fa972dd63d..eb9935c2d2f 100644 --- a/backends/cortex_m/quantizer/quantizer.py +++ b/backends/cortex_m/quantizer/quantizer.py @@ -16,6 +16,7 @@ from executorch.backends.cortex_m.passes.cortex_m_pass_manager import CortexMPassManager from executorch.backends.cortex_m.quantizer.node_finders import ( GlobalNoOutputNodeFinder, + NodeTargetActivationNodeFinder, NodeTargetNodeFinder, ) from executorch.backends.cortex_m.quantizer.pattern_matcher import PatternMatcher @@ -47,6 +48,11 @@ def mark_node_as_annotated( class CortexMQuantizer(ComposableQuantizer): + """Convolutions use ``INT8_PER_CHANNEL_CONFIG`` and every other supported op + ``per_tensor_config``. ``set_node_target`` overrides the config of an op, + and each call overrides those before it. + """ + def __init__( self, per_tensor_config: Optional[QuantizationConfig] = None, @@ -58,7 +64,7 @@ def __init__( per_tensor_config: Per-tensor activation config applied to the non-conv elementwise ops (div/mul/add/...) that ``GlobalNodeFinder`` matches anywhere in the graph. Convolutions - are always quantized with the per-channel config. Defaults to + default to the per-channel config. Defaults to ``INT8_PER_TENSOR_CONFIG``; pass ``INT16_PER_TENSOR_CONFIG`` to quantize the ops that support it (e.g. ``quantized_div``) with int16 activations. @@ -85,7 +91,7 @@ def __init__( for key in conv_patterns: conv_targets.update(key) - pattern_matcher = PatternMatcher( + self.pattern_matcher = PatternMatcher( cast( dict[tuple[OpOverload, ...], Optional[type[PatternCheck]]], support_dict, @@ -96,17 +102,48 @@ def __init__( PatternQuantizer( INT8_PER_CHANNEL_CONFIG, node_finder=NodeTargetNodeFinder(list(conv_targets)), - pattern_matcher=pattern_matcher, + pattern_matcher=self.pattern_matcher, ), PatternQuantizer( per_tensor_config, node_finder=GlobalNoOutputNodeFinder(), - pattern_matcher=pattern_matcher, + pattern_matcher=self.pattern_matcher, ), SharedQspecQuantizer(), ] super().__init__(quantizers) + def set_node_target( + self, node_target: OpOverload, quantization_config: Optional[QuantizationConfig] + ) -> "CortexMQuantizer": + """Quantize every node calling `node_target`, and the activations its + patterns fuse after it, with `quantization_config`, e.g. + ``torch.ops.aten.conv2d.default`` with ``INT8_PER_TENSOR_CONFIG`` for + per-tensor convolution weights. ``None`` leaves them unquantized, and + works for any op. + """ + if not isinstance(node_target, OpOverload): + raise TypeError( + "set_node_target: expected an OpOverload such as " + f"torch.ops.aten.conv2d.default, got {node_target}" + ) + patterns = [p for p in self.pattern_matcher.support_dict if p[0] == node_target] + if quantization_config is not None and not patterns: + raise ValueError( + f"Quantization of {node_target} is not supported by the " + "cortex-m backend" + ) + activations = list(dict.fromkeys(op for p in patterns for op in p[1:])) + self.quantizers.insert( + 0, + PatternQuantizer( + quantization_config, + node_finder=NodeTargetActivationNodeFinder(node_target, activations), + pattern_matcher=self.pattern_matcher, + ), + ) + return self + def annotate(self, model): reporter = QuantizerReporter(self.quantizers) model = super().annotate(model) diff --git a/backends/cortex_m/test/misc/test_node_finders.py b/backends/cortex_m/test/misc/test_node_finders.py index 5fb6b4eb29d..f5ec66ca3a4 100644 --- a/backends/cortex_m/test/misc/test_node_finders.py +++ b/backends/cortex_m/test/misc/test_node_finders.py @@ -94,6 +94,52 @@ def test_node_target_finder_missing_target(): assert str(node_finder) == "NodeTargetNodeFinder targeting node targets: aten::relu" +def test_node_target_activation_finder(): + """Test NodeTargetActivationNodeFinder skips activations not consuming the target.""" + + class LinearRelu(torch.nn.Module): + def __init__(self): + super().__init__() + self.linear = torch.nn.Linear(4, 4) + + def forward(self, x: torch.Tensor): + return torch.relu(self.linear(torch.relu(x))) + + graph_module = export(LinearRelu(), (torch.ones(4),)).graph_module + + node_finder = node_finders.NodeTargetActivationNodeFinder( + torch.ops.aten.linear.default, torch.ops.aten.relu.default + ) + nodes = list(node_finder.find_nodes(graph_module)) + assert [n.name for n in nodes] == ["linear", "relu_1"] + assert ( + str(node_finder) + == "NodeTargetActivationNodeFinder targeting node target: aten::linear " + "with activations: aten::relu" + ) + + +def test_node_target_activation_finder_skips_forked_target(): + """Test NodeTargetActivationNodeFinder skips activations of a target with other users.""" + + class ForkedLinearRelu(torch.nn.Module): + def __init__(self): + super().__init__() + self.linear = torch.nn.Linear(4, 4) + + def forward(self, x: torch.Tensor): + y = self.linear(x) + return torch.relu(y) + y + + graph_module = export(ForkedLinearRelu(), (torch.ones(4),)).graph_module + + node_finder = node_finders.NodeTargetActivationNodeFinder( + torch.ops.aten.linear.default, torch.ops.aten.relu.default + ) + nodes = list(node_finder.find_nodes(graph_module)) + assert [n.name for n in nodes] == ["linear"] + + def test_global_node_finder(): """Test GlobalNodeFinder finds all nodes.""" graph_module = _export_graph_module() diff --git a/backends/cortex_m/test/misc/test_quantization.py b/backends/cortex_m/test/misc/test_quantization.py index 4b3c8e971ae..9253f8d2a3a 100644 --- a/backends/cortex_m/test/misc/test_quantization.py +++ b/backends/cortex_m/test/misc/test_quantization.py @@ -4,14 +4,21 @@ # LICENSE file in the root directory of this source tree. +import pytest import torch from executorch.backends.arm._passes.arm_pass_utils import get_first_fake_tensor from executorch.backends.arm.test.common import parametrize +from executorch.backends.cortex_m.quantizer.quantization_configs import ( + INT8_PER_CHANNEL_CONFIG, + INT8_PER_TENSOR_CONFIG, +) +from executorch.backends.cortex_m.quantizer.quantizer import CortexMQuantizer from executorch.backends.cortex_m.test.tester import ( CortexMTester, McuTestCase, ramp_tensor, ) +from executorch.backends.test.harness.stages import Quantize, StageType from executorch.exir.dialects._ops import ops as exir_ops @@ -405,3 +412,91 @@ def test_shared_qspec_quantizer_no_qspecs(test_case): tester.test_dialect( test_case.model.ops_before_transforms, test_case.model.ops_after_transforms ) + + +def _conv_inputs(): + return (ramp_tensor(-2, 2, (1, 2, 6, 6)).to(memory_format=torch.channels_last),) + + +def _has_activation(targets): + # An unfused quantized relu lowers to clamp. + return any("relu" in t or "clamp" in t for t in targets) + + +@pytest.mark.parametrize( + "configs, per_channel_weights", + [ + ([INT8_PER_TENSOR_CONFIG], 0), + ([INT8_PER_TENSOR_CONFIG, INT8_PER_CHANNEL_CONFIG], 1), + ], +) +def test_set_node_target_overrides_earlier_configs(configs, per_channel_weights): + # Convolutions default to per-channel weights; the last set_node_target + # call decides. + quantizer = CortexMQuantizer() + for config in configs: + quantizer.set_node_target(torch.ops.aten.conv2d.default, config) + model = torch.nn.Conv2d(2, 4, 3, bias=False).eval() + tester = CortexMTester(model, _conv_inputs()) + tester.quantize(Quantize(quantizer)).export().to_edge() + tester.check_count( + { + "executorch_exir_dialects_edge__ops_quantized_decomposed_dequantize_per_channel_default": per_channel_weights + } + ) + + +def test_set_node_target_keeps_fused_activation(): + # The override must still match conv + relu as one pattern, so the relu + # folds into the convolution. + model = torch.nn.Sequential(torch.nn.Conv2d(2, 4, 3), torch.nn.ReLU()).eval() + quantizer = CortexMQuantizer().set_node_target( + torch.ops.aten.conv2d.default, INT8_PER_TENSOR_CONFIG + ) + tester = CortexMTester(model, _conv_inputs()) + tester.quantize(Quantize(quantizer)).export().to_edge().run_passes() + tester.run_method_and_compare_outputs(inputs=_conv_inputs(), qtol=1) + program = tester.get_artifact(StageType.RUN_PASSES).exported_program() + targets = [str(n.target) for n in program.graph.nodes if n.op == "call_function"] + assert any("quantized_conv2d" in t for t in targets) + assert not _has_activation(targets) + + +@pytest.mark.parametrize( + "node_target, linear_quantized, relu_folded", + [ + (torch.ops.aten.conv2d.default, True, True), + (torch.ops.aten.relu.default, True, False), + (torch.ops.aten.linear.default, False, False), + ], +) +def test_set_node_target_none_leaves_node_target_unquantized( + node_target, linear_quantized, relu_folded +): + # Linear + relu fuses unless one of them is left unquantized; relu starts + # no pattern, but None still applies to it. + model = torch.nn.Sequential(torch.nn.Linear(4, 4), torch.nn.ReLU()).eval() + quantizer = CortexMQuantizer().set_node_target(node_target, None) + tester = CortexMTester(model, (ramp_tensor(-2, 2, (1, 4)),)) + tester.quantize(Quantize(quantizer)).export().to_edge().run_passes() + program = tester.get_artifact(StageType.RUN_PASSES).exported_program() + targets = [str(n.target) for n in program.graph.nodes if n.op == "call_function"] + assert any("quantized_linear" in t for t in targets) == linear_quantized + assert _has_activation(targets) != relu_folded + + +@pytest.mark.parametrize( + "node_target, config, error, match", + [ + ( + torch.ops.aten.conv2d.padding, + INT8_PER_TENSOR_CONFIG, + ValueError, + "is not supported by the cortex-m backend", + ), + (torch.ops.aten.linear, None, TypeError, "expected an OpOverload"), + ], +) +def test_set_node_target_rejects_invalid_target(node_target, config, error, match): + with pytest.raises(error, match=match): + CortexMQuantizer().set_node_target(node_target, config) From dadc829202384a00e5968a7e8226c0a106f2b92a Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Wed, 4 Nov 2026 13:28:06 -0800 Subject: [PATCH 068/128] Core ML: run ops on the CPU when Core ML cannot take or return their values (#24317) ## What is wrong today coremltools cannot take a uint8 or int8 tensor as a Core ML input, such as camera pixels or int8 tensors in a quantized model. Lowering fails with `KeyError: torch.uint8`. An input sized by an expression such as `s0 + 1` fails too, unless the program declares it. Some values cannot leave Core ML either: the delegate has no int8 data type and returns only tensors, so neither an int8 tensor nor a symbolic value, such as a size, can be an output. `ops_to_not_decompose` can also keep an op whole that then runs on the CPU, where the portable kernels may lack it, such as the matmul of a bfloat16 linear. `zeros_like` and `fill` put a constant as big as their input into the model when its shape is known, and since #24424 `ones_like` and `full_like` do too. ## What this change does - The partitioner leaves on the CPU each op that reads such a tensor, or a symbolic value computed on the CPU, from outside its partition, and each op that makes an int8 tensor or a symbolic value used outside its partition. Weights, constants and state that Core ML takes over are not inputs. The search repeats as ops move out, and partitions left reading only sizes, or nothing below iOS 18, move out too. - State stays in the program when no op reads it, or when a CPU op or a second partition also uses it or the model returns its new value. An op that reads such uint8 or int8 state, or makes its new int8 value, moves to the CPU. - With `lower_full_graph=True`, a Core ML part that would return an int8 tensor now fails while lowering, with the reason, not at run time. - `ops_to_not_decompose` stops keeping an op whole when the partitioner moves some call of it to the CPU, or when Core ML rejects a call that decomposing makes. It decomposes the program as lowering will only when that may change the answer. - From iOS 16, those four fills use Core ML's `fill_like` instead. - The docs list the values Core ML cannot take or return, and point camera apps to the `ImageProcessor` in `extension/image`. ## Testing - `test_coreml_partitioner.py`, `test_torch_ops.py` and `test_coreml_recipes.py` pass on Linux and macOS, where most new tests also compare outputs with eager PyTorch. - Most new tests fail on main. --- backends/apple/coreml/compiler/torch_ops.py | 21 +- .../coreml/partition/coreml_partitioner.py | 806 ++++++++++++- .../coreml/test/test_coreml_partitioner.py | 1000 ++++++++++++++++- backends/apple/coreml/test/test_torch_ops.py | 37 + .../backends/coreml/coreml-partitioner.md | 8 +- 5 files changed, 1828 insertions(+), 44 deletions(-) diff --git a/backends/apple/coreml/compiler/torch_ops.py b/backends/apple/coreml/compiler/torch_ops.py index ed9cf0d5e18..3a914f7acbc 100644 --- a/backends/apple/coreml/compiler/torch_ops.py +++ b/backends/apple/coreml/compiler/torch_ops.py @@ -13,7 +13,7 @@ import numpy as np import torch as _torch -from coremltools import _logger +from coremltools import _logger, target from coremltools.converters.mil import Builder as mb from coremltools.converters.mil.frontend import _utils from coremltools.converters.mil.frontend.torch import ops as _ct_ops @@ -31,6 +31,9 @@ register_torch_op, ) from coremltools.converters.mil.mil import types +from coremltools.converters.mil.mil.block import ( + is_current_opset_version_compatible_with, +) from coremltools.converters.mil.mil.types.symbolic import is_symbolic from executorch.exir.dim_order_utils import get_memory_format @@ -92,6 +95,16 @@ def _add_fill(context, node, shape, value): context.add(_make_fill_op(shape, _core_ml_type(node)(value), node.name)) +def _add_fill_like(context, node, x, value): + # A fill of a shape known at conversion becomes a constant as big as x in + # the model, which fill_like avoids from iOS 16. + if is_current_opset_version_compatible_with(target.iOS16): + value = _core_ml_type(node)(value) + context.add(mb.fill_like(ref_tensor=x, value=value, name=node.name)) + else: + _add_fill(context, node, mb.shape(x=x), value) + + # coremltools gives these fills the type of their fill value or of their input, # or makes them float, instead of the dtype torch gives them, so an int or bool # fill could come out as float. https://github.com/apple/coremltools/pull/2865 @@ -119,7 +132,7 @@ def _fill_like(context, node, convert): inputs = _get_inputs(context, node, min_expected=2) if _torch_dtype(node) is None or inputs[1].val is None: return convert(context, node) - _add_fill(context, node, mb.shape(x=inputs[0]), inputs[1].val) + _add_fill_like(context, node, inputs[0], inputs[1].val) @register_torch_op(override=True) @@ -153,7 +166,7 @@ def zeros_like(context, node): if _torch_dtype(node) is None: return _ct_ops.zeros_like(context, node) x = _get_inputs(context, node, min_expected=1)[0] - _add_fill(context, node, mb.shape(x=x), 0) + _add_fill_like(context, node, x, 0) @register_torch_op(override=True) @@ -161,7 +174,7 @@ def ones_like(context, node): if _torch_dtype(node) is None: return _ct_ops.ones_like(context, node) x = _get_inputs(context, node, min_expected=1)[0] - _add_fill(context, node, mb.shape(x=x), 1) + _add_fill_like(context, node, x, 1) @register_torch_op(override=True) diff --git a/backends/apple/coreml/partition/coreml_partitioner.py b/backends/apple/coreml/partition/coreml_partitioner.py index 895f47e6443..962f149698a 100644 --- a/backends/apple/coreml/partition/coreml_partitioner.py +++ b/backends/apple/coreml/partition/coreml_partitioner.py @@ -4,7 +4,9 @@ import inspect import logging -from typing import Callable, List, Optional, Tuple +import operator +import weakref +from typing import Any, Callable, Collection, Dict, List, Optional, Set, Tuple import coremltools as ct @@ -25,10 +27,26 @@ ) from executorch.exir.backend.utils import tag_constant_data, tag_mutated_buffer from executorch.exir.dialects._ops import ops as exir_ops -from torch.export.exported_program import ExportedProgram -from torch.fx.experimental.symbolic_shapes import statically_known_true, sym_eq -from torch.fx.passes.infra.partitioner import CapabilityBasedPartitioner -from torch.fx.passes.operator_support import OperatorSupportBase +from executorch.exir.passes.dim_order_ops_registry import DimOrderOpsMap +from executorch.exir.passes.replace_aten_with_edge_pass import aten_to_edge +from executorch.exir.tracer import _default_decomposition_table +from torch._export.error import InternalError +from torch._export.passes.replace_view_ops_with_view_copy_ops_pass import ( + get_view_copy_of_view_op, +) +from torch.export.exported_program import ExportedProgram, OutputKind +from torch.fx.experimental.proxy_tensor import make_fx +from torch.fx.experimental.symbolic_shapes import ( + is_concrete_int, + statically_known_true, + sym_eq, +) +from torch.fx.passes.infra.partitioner import CapabilityBasedPartitioner, Partition +from torch.fx.passes.operator_support import ( + chain, + create_op_support, + OperatorSupportBase, +) logger = logging.getLogger(__name__) logger.setLevel(get_coreml_log_level(default_level=logging.INFO)) @@ -205,6 +223,489 @@ def _reduces_0d_tensor(node: torch.fx.Node) -> bool: return isinstance(val, torch.Tensor) and val.dim() == 0 +def _makes_tensor( + node: torch.fx.Node, dtypes: Optional[Collection[torch.dtype]] = None +) -> bool: + """Whether node makes a tensor, of one of dtypes if given.""" + val = node.meta.get("val") + vals = val if isinstance(val, (list, tuple)) else [val] + return any( + isinstance(v, torch.Tensor) and (dtypes is None or v.dtype in dtypes) + for v in vals + ) + + +def _makes_symbolic_value(node: torch.fx.Node) -> bool: + """Whether node makes a symbolic value, such as a size known only at run + time.""" + return isinstance( + node.meta.get("val"), (torch.SymInt, torch.SymFloat, torch.SymBool) + ) + + +def _dynamic_size_names(ep: ExportedProgram) -> Set[str]: + """The names of the dynamic sizes ep has range constraints for, which are + the sizes coremltools can give a Core ML input besides numbers.""" + return {str(symbol) for symbol in ep.range_constraints} + + +# https://github.com/pytorch/executorch/issues/11686 +def _cannot_be_coreml_input(node: torch.fx.Node, sizes: Collection[str]) -> bool: + """Whether node makes a tensor Core ML cannot take as a program input: a + uint8 or int8 tensor, or one with a size that is neither a number nor in + sizes, such as s0 + 1. + + coremltools types each program input through ``TORCH_DTYPE_TO_MIL_DTYPE`` + and raises ``KeyError`` for a dtype missing from it instead of reporting the + op as unsupported. Only uint8 and int8 move to the CPU: the portable kernels + handle them, while other missing dtypes, such as uint16, would only fail + later, at run time. It looks each dynamic size up by name in the range + constraints of the program, and raises ``ValueError`` for one it cannot + find. + """ + val = node.meta.get("val") + vals = val if isinstance(val, (list, tuple)) else [val] + return any( + isinstance(v, torch.Tensor) + and ( + v.dtype in (torch.uint8, torch.int8) + or any( + not is_concrete_int(size) and str(size) not in sizes for size in v.shape + ) + ) + for v in vals + ) + + +def _cannot_be_read_from_outside( + arg: torch.fx.Node, + kept_inside: Collection[str], + sizes: Collection[str], + outside_coreml: Callable[[torch.fx.Node], bool], +) -> bool: + """Whether a Core ML op cannot read arg from outside its partition: a + tensor Core ML cannot take as an input, or a symbolic value computed + outside of Core ML, which it cannot take either. A weight, constant or + state the partitioner moves into Core ML is not an input. + """ + return arg.name not in kept_inside and ( + _cannot_be_coreml_input(arg, sizes) + or (_makes_symbolic_value(arg) and outside_coreml(arg)) + ) + + +def _find_readers_of_unsupported_inputs( + partitions: List[Partition], kept_inside: Collection[str], sizes: Collection[str] +) -> Set[torch.fx.Node]: + """The nodes that read a tensor Core ML cannot take as an input from outside + their partition, or a symbolic value computed outside of Core ML. + """ + inside = {node for partition in partitions for node in partition.nodes} + return { + node + for partition in partitions + for node in partition.nodes + if any( + arg not in partition.nodes + and _cannot_be_read_from_outside( + arg, kept_inside, sizes, lambda arg: arg not in inside + ) + for arg in node.all_input_nodes + ) + } + + +def _cannot_be_coreml_output(node: torch.fx.Node) -> bool: + """Whether node makes a value the Core ML delegate cannot return. + + The delegate has no int8 data type to hand an int8 tensor back to + ExecuTorch, and it returns only tensors, so a symbolic value cannot be an + output either. + """ + return _makes_tensor(node, (torch.int8,)) or _makes_symbolic_value(node) + + +def _find_makers_of_unsupported_outputs( + partitions: List[Partition], state_writes: Collection[torch.fx.Node] +) -> Set[torch.fx.Node]: + """The nodes that make a value the Core ML delegate cannot return, for use + outside their partition. A write of state that Core ML keeps is no output. + + An op with several outputs hands each one out through a getitem, which + always stays with the op, so the op itself is returned. + """ + return { + node.args[0] if node.target is operator.getitem else node + for partition in partitions + for node in partition.nodes + if _cannot_be_coreml_output(node) + and any( + user not in partition.nodes + and not (user.op == "output" and node in state_writes) + for user in node.users + ) + } + + +def _find_unsupported_inputs_of_left_out_ops( + left_out: Set[torch.fx.Node], + inside: Set[torch.fx.Node], + kept_inside: Collection[str], + sizes: Collection[str], +) -> Set[torch.fx.Node]: + """The tensors Core ML cannot take as an input that explain why the ops in + left_out run outside of Core ML.""" + read: Set[torch.fx.Node] = set() + for node in left_out: + args = [ + arg + for arg in node.all_input_nodes + if arg.name not in kept_inside + and isinstance(arg.meta.get("val"), torch.Tensor) + and _cannot_be_coreml_input(arg, sizes) + ] + # A value the delegate cannot return, made by an op left out of Core + # ML, is no reason for its readers: that op may be out only for them. + outside = [ + arg + for arg in args + if arg not in inside + and not (arg in left_out and _cannot_be_coreml_output(arg)) + ] + if outside: + read.update(arg for arg in outside if arg not in left_out) + # A tensor that a Core ML partition makes explains the op only when no + # tensor from outside does, and the op is not left out for making a + # value the delegate cannot return. + elif not ( + _cannot_be_coreml_output(node) + and any(user not in left_out for user in node.users) + ): + read.update(arg for arg in args if arg in inside) + return read + + +def _find_nodes_of_partitions_without_tensor_inputs( + partitions: List[Partition], + kept_inside: Collection[str], + allow_partitions_without_inputs: bool, +) -> Set[torch.fx.Node]: + """The nodes of the partitions that read no tensor from outside, except the + weights, constants and state the partitioner moves into Core ML. + + Core ML cannot take a symbolic size as an input, and below iOS 18 it + rejects a model with no inputs, so such a partition cannot be delegated. + """ + nodes = set() + for partition in partitions: + inputs = [ + arg + for node in partition.nodes + for arg in node.all_input_nodes + if arg not in partition.nodes and arg.name not in kept_inside + ] + reads_tensor = any(_makes_tensor(arg) for arg in inputs) + if not reads_tensor and (inputs or not allow_partitions_without_inputs): + nodes.update(partition.nodes) + return nodes + + +def _find_ops_left_out_of_any_partition( + graph_module: torch.fx.GraphModule, + op_support: OperatorSupportBase, + kept_inside: Collection[str], + sizes: Collection[str], + state_writes: Collection[torch.fx.Node], +) -> Set[torch.fx.Node]: + """The ops _propose_partitions leaves out however it partitions the rest: + each op that reads a tensor Core ML cannot take as an input, or a symbolic + value, from an op outside of Core ML, and each op that makes an int8 tensor + or a symbolic value an op outside of Core ML uses, other than to write + state Core ML may keep. + """ + left_out: Set[torch.fx.Node] = set() + supported = { + node + for node in graph_module.graph.nodes + if op_support.is_node_supported(None, node) + } + + def outside(node: torch.fx.Node) -> bool: + return node in left_out or node not in supported + + # Leaving an op out can make an op before or after it read or make such a + # tensor across the edge of Core ML, so repeat until nothing changes. + changed = True + while changed: + changed = False + for node in graph_module.graph.nodes: + if outside(node): + continue + if any( + outside(arg) + and _cannot_be_read_from_outside(arg, kept_inside, sizes, outside) + for arg in node.all_input_nodes + ): + left_out.add(node) + changed = True + elif _cannot_be_coreml_output(node) and any( + outside(user) and not (user.op == "output" and node in state_writes) + for user in node.users + ): + maker = node.args[0] if node.target is operator.getitem else node + changed |= maker not in left_out + left_out.add(maker) + return left_out + + +def _computed_from_kept_inside( + graph_module: torch.fx.GraphModule, kept_inside: Collection[str] +) -> Set[torch.fx.Node]: + """The nodes that read no tensor but the weights, constants and state the + partitioner moves into Core ML, or nodes computed from them.""" + nodes: Set[torch.fx.Node] = set() + for node in graph_module.graph.nodes: + if node.op == "placeholder": + if node.name in kept_inside: + nodes.add(node) + elif all(arg in nodes for arg in node.all_input_nodes if _makes_tensor(arg)): + nodes.add(node) + return nodes + + +def _find_ops_partitioning_may_leave_out( + graph_module: torch.fx.GraphModule, + kept_inside: Collection[str], + sizes: Collection[str], + is_supported: Callable[[torch.fx.Node], bool], + states: Collection[str], +) -> Set[torch.fx.Node]: + """The ops _propose_partitions may leave out of Core ML. + + Those that read a tensor Core ML cannot take as an input, make a value the + delegate cannot return, or read a symbolic value from an op that may be + outside of Core ML set it off. State can stay in the program, so reading it + can be reading an input. Once it leaves out one, a partition left without + tensor inputs moves out too, and such a partition holds only ops that + compute from the weights, constants and state the partitioner moves into + Core ML. + """ + weights_and_constants = set(kept_inside).difference(states) + set_off: Set[torch.fx.Node] = set() + for node in graph_module.graph.nodes: + if node.op == "placeholder": + continue + if _cannot_be_coreml_output(node) or any( + _cannot_be_read_from_outside( + arg, + weights_and_constants, + sizes, + lambda arg: arg in set_off or not is_supported(arg), + ) + for arg in node.all_input_nodes + ): + set_off.add(node) + if not set_off: + return set() + return set_off | _computed_from_kept_inside(graph_module, kept_inside) + + +def _states_left_in_program( + partitions: List[Partition], + state_writers: Dict[torch.fx.Node, List[torch.fx.Node]], +) -> Set[str]: + """The state tag_mutated_buffer leaves in the program for these partitions: + state that an op outside one partition reads or writes, also through its + new value. + """ + partition_of = { + node: partition.id for partition in partitions for node in partition.nodes + } + left = set() + for state, writers in state_writers.items(): + users = [*state.users, *writers] + users += [ + user for writer in writers for user in writer.users if user.op != "output" + ] + ids = {partition_of.get(user) for user in users} + if None in ids or len(ids) > 1: + left.add(state.name) + return left + + +def _propose_partitions( + graph_module: torch.fx.GraphModule, + op_support: OperatorSupportBase, + kept_inside: Collection[str], + sizes: Collection[str], + allow_partitions_without_inputs: bool, + state_writers: Dict[torch.fx.Node, List[torch.fx.Node]], +) -> Tuple[List[Partition], Set[torch.fx.Node]]: + """Propose Core ML partitions, leaving out every op that reads a value + Core ML cannot take as an input from outside its partition or makes one + the delegate cannot return for use outside, and then every partition this + leaves without a tensor input. State the partitions leave in the program + is an input. + + Returns the partitions and the ops left out. + """ + # Searching for partitions is slow on a large graph, so first leave out + # the ops that do not depend on how the search goes. Most graphs then need + # a single search. + left_out = _find_ops_left_out_of_any_partition( + graph_module, + op_support, + kept_inside, + sizes, + {writer for writers in state_writers.values() for writer in writers}, + ) + support = chain(create_op_support(lambda _, node: node not in left_out), op_support) + # Each op moved out of Core ML can hand such a tensor to the next op, so + # repeat until no partition reads one. + while True: + partitions = CapabilityBasedPartitioner( + graph_module, support, allows_single_node_partition=True + ).propose_partitions() + left_in_program = _states_left_in_program(partitions, state_writers) + kept = set(kept_inside).difference(left_in_program) + state_writes = { + writer + for state, writers in state_writers.items() + if state.name not in left_in_program + for writer in writers + } + new = _find_readers_of_unsupported_inputs( + partitions, kept, sizes + ) | _find_makers_of_unsupported_outputs(partitions, state_writes) + if new or left_out: + # Moving those ops out can leave a partition that reads no tensor, + # such as the index math of a gather. So once any op moves out, + # every such partition that Core ML cannot take moves out too. If + # none does, they stay: below iOS 18, lowering then fails on a + # partition with no inputs and asks for iOS 18. + # _find_ops_partitioning_may_leave_out relies on this. + new |= _find_nodes_of_partitions_without_tensor_inputs( + partitions, kept, allow_partitions_without_inputs + ) + # Stop once a round leaves out nothing new, so the loop always ends. + new -= left_out + if not new: + return partitions, left_out + left_out |= new + + +def _decompose_alone( + node: torch.fx.Node, decompositions: Dict[torch._ops.OperatorBase, Callable] +) -> torch.fx.GraphModule: + """Traces the decomposition of node by itself, from the values of its + inputs.""" + inputs = node.all_input_nodes + + def call(*args): + env = dict(zip(inputs, args)) + return node.target( + *torch.fx.node.map_arg(node.args, env.__getitem__), + **torch.fx.node.map_arg(node.kwargs, env.__getitem__), + ) + + # pre_dispatch keeps whole a composite op the table leaves out, such as + # matmul, as lowering does. + return make_fx( + call, + decomposition_table=decompositions, + tracing_mode="fake", + pre_dispatch=True, + )(*(arg.meta["val"] for arg in inputs)) + + +def _decomposing_may_leave_out( + graph_module: torch.fx.GraphModule, + decompositions: Dict[torch._ops.OperatorBase, Callable], + not_kept: Collection[torch._ops.OpOverload], + kept: Collection[torch._ops.OpOverload], + from_kept_inside: Collection[torch.fx.Node], + sizes: Collection[str], + is_supported: Callable[[torch.fx.Node], bool], + may_leave_out: bool, + log: Callable[[str], None], +) -> bool: + """Whether decomposing the calls of the ops lowering decomposes may make a + call of a kept op that Core ML rejects, or make partition() leave out a + call of a kept op it does not leave out in graph_module. may_leave_out + says whether partition() may leave out some op there. + + That needs a decomposition of a not_kept op that makes a call of a kept op + that Core ML rejects, or that reads or makes a value Core ML cannot take or + return. A decomposed call of any op that reads or makes such a value may + also be the first op partition() moves out, after which a partition left + without tensor inputs moves out too. So it matters once a kept call can end + up in such a partition: a kept call that reads only the weights, constants + and state the partitioner moves into Core ML, or a call of a not_kept op + that reads only those and makes a tensor or a call of a kept op. If + may_leave_out, such a call of a not_kept op matters by itself. Each call is + traced by itself, in milliseconds, while decomposing a large program takes + minutes. A call that cannot be traced by itself may matter. + """ + + def crosses(call: torch.fx.Node) -> bool: + return _cannot_be_coreml_output(call) or any( + _cannot_be_coreml_input(arg, sizes) or _makes_symbolic_value(arg) + for arg in call.all_input_nodes + ) + + crossing = False + without_inputs = any(node.target in kept for node in from_kept_inside) + for node in graph_module.graph.nodes: + if node.target not in decompositions: + continue + not_kept_call = node.target in not_kept + reads_kept_inside = not_kept_call and all( + arg in from_kept_inside + for arg in node.all_input_nodes + if _makes_tensor(arg) + ) + from_inside = may_leave_out and reads_kept_inside + if from_inside and _makes_tensor(node): + return True + try: + calls = list(_decompose_alone(node, decompositions).graph.nodes) + except Exception as e: + log( + f"Decomposing the program to find the ops that run outside of " + f"CoreML, because {node.target} cannot be decomposed by itself: {e}" + ) + return True + if not_kept_call and any( + call.target in kept + and (from_inside or not is_supported(call) or crosses(call)) + for call in calls + ): + return True + crossing = crossing or any(crosses(call) for call in calls) + without_inputs = without_inputs or ( + reads_kept_inside + and (_makes_tensor(node) or any(call.target in kept for call in calls)) + ) + return crossing and without_inputs + + +def _edge_op_names(target: Any) -> Set[str]: + """The lowercase names target can have once lowered to the edge dialect, + which partition() matches skip names against: an ATen op gains its + namespace, a view op becomes its copy, and some ops become dim order ops. + """ + if not isinstance(target, torch._ops.OpOverload): + return {getattr(target, "__name__", "").lower()} + try: + view_copy = get_view_copy_of_view_op(target._schema) + except InternalError: + # A view op without a copy, such as reshape in a program as exported, + # is turned into views by lowering before partition() sees it. + view_copy = None + edge = aten_to_edge(view_copy or target) + return {edge.__name__.lower(), DimOrderOpsMap.get(edge, edge).__name__.lower()} + + def _is_view_op(op: torch._ops.OpOverload) -> bool: schema = op._schema if len(schema.arguments) == 0: @@ -418,23 +919,6 @@ def override_reason(self, node) -> Optional[str]: if refusal is not None: return f"{name} {refusal} is not supported by CoreML." - # TODO: enable this after bugs in ExecuTorch's partitioner are fixed - # # If lower_full_graph=False, do not partition nodes with symbolic args because it can result in symbolic args - # # in the placeholders due to partitioning, which CoreML does not support - # if not self.lower_full_graph and any( - # isinstance(arg, torch.fx.Node) - # and isinstance( - # arg.meta.get("val", None), - # (torch.SymInt, torch.SymBool, torch.SymFloat), - # ) - # for arg in node.args - # ): - # self.log_once( - # "Skipping op for CoreML delegation because it contains symbolic args: " - # + node_target_name - # ) - # return True - return None def is_node_supported(self, submodules, node: torch.fx.Node) -> bool: @@ -525,6 +1009,7 @@ def __init__( ) ) self._logged_msgs = set() + self._ops_to_not_decompose_cache = None if self.lower_full_graph: assert ( @@ -574,7 +1059,10 @@ def partition(self, exported_program: ExportedProgram) -> PartitionResult: logger.info("CoreMLPartitioner::partition") partition_tags = {} - capability_partitioner = CapabilityBasedPartitioner( + state_writers = self._state_writers(exported_program) + kept_inside = self._placeholders_kept_inside(exported_program, state_writers) + sizes = _dynamic_size_names(exported_program) + partition_list, left_out = _propose_partitions( exported_program.graph_module, _OperatorsSupportedForCoreMLBackend( self.skip_ops_for_coreml_delegation, @@ -582,9 +1070,79 @@ def partition(self, exported_program: ExportedProgram) -> PartitionResult: log=True, minimum_deployment_target=self._minimum_deployment_target, ), - allows_single_node_partition=True, + kept_inside, + sizes, + self._targets_ios18(), + state_writers, ) - partition_list = capability_partitioner.propose_partitions() + if left_out: + kept = set(kept_inside).difference( + _states_left_in_program(partition_list, state_writers) + ) + inside = {node for partition in partition_list for node in partition.nodes} + inputs = sorted( + f"{arg.name} ({arg.meta['val'].dtype}, {tuple(arg.meta['val'].shape)})" + for arg in _find_unsupported_inputs_of_left_out_ops( + left_out, inside, kept, sizes + ) + ) + outputs = sorted( + node.name + for node in left_out + if _makes_tensor(node, (torch.int8,)) + and any(user not in left_out for user in node.users) + ) + symbols = sorted( + node.name + for node in left_out + if ( + _makes_symbolic_value(node) + and any(user not in left_out for user in node.users) + ) + or any( + _makes_symbolic_value(arg) and arg not in inside + for arg in node.all_input_nodes + ) + ) + reasons = [] + if inputs: + reasons.append( + "reads a tensor CoreML cannot take as an input, of uint8 or " + "int8 or with a size computed from a dynamic size, from " + "outside a CoreML partition: " + ", ".join(inputs) + ) + if outputs: + reasons.append( + "makes an int8 tensor, which the CoreML delegate cannot " + "return, for use outside of CoreML: " + ", ".join(outputs) + ) + if symbols: + reasons.append( + "makes or reads a symbolic value, which CoreML can neither " + "take as an input nor return, outside of CoreML: " + + ", ".join(symbols) + ) + msg = ( + f"The model {', and '.join(reasons)}." + if reasons + else "The model uses a tensor or a symbolic value CoreML cannot handle." + ) + if self.lower_full_graph: + raise NotImplementedError( + f"{msg} Set lower_full_graph=False in the CoreMLPartitioner " + "to run these ops outside of CoreML." + ) + self.log_once( + f"{msg} Running these ops outside of CoreML: " + + ", ".join( + sorted( + { + getattr(node.target, "__name__", str(node.target)) + for node in left_out + } + ) + ) + ) for partition in partition_list: for node in partition.nodes: tag = f"tag{partition.id}" @@ -609,12 +1167,70 @@ def partition(self, exported_program: ExportedProgram) -> PartitionResult: def log_once(self, msg: str) -> None: if msg not in self._logged_msgs: - logging.info(msg) + logger.info(msg) self._logged_msgs.add(msg) + def _targets_ios18(self) -> bool: + """Whether the deployment target is iOS 18 or later.""" + target = CoreMLBackend.min_deployment_target_from_compile_specs( + self.delegation_spec.compile_specs + ) + return target is not None and target >= ct.target.iOS18 + + def _placeholders_kept_inside( + self, + ep: ExportedProgram, + state_writers: Dict[torch.fx.Node, List[torch.fx.Node]], + ) -> Set[str]: + """The weights, constants and state this partitioner moves into Core ML.""" + signature = ep.graph_signature + mutated = set(signature.buffers_to_mutate.values()) + kept = {state.name for state in state_writers} + if self.take_over_constant_data: + kept |= set(signature.inputs_to_parameters) + kept |= set(signature.inputs_to_lifted_tensor_constants) + kept |= { + name + for name, buffer in signature.inputs_to_buffers.items() + if buffer not in mutated + } + return kept + + def _state_writers( + self, ep: ExportedProgram + ) -> Dict[torch.fx.Node, List[torch.fx.Node]]: + """The nodes that write each mutable buffer this partitioner may move + into Core ML as state, by the placeholder that reads it. A buffer that + no op reads, or whose new value is also returned, stays in the program. + """ + if not self.take_over_mutable_buffer: + return {} + signature = ep.graph_signature + nodes = {node.name: node for node in ep.graph.nodes} + returned = { + spec.arg.name + for spec in signature.output_specs + if spec.kind == OutputKind.USER_OUTPUT + } + writers: Dict[str, List[torch.fx.Node]] = {} + for name, buffer in signature.buffers_to_mutate.items(): + writers.setdefault(buffer, []).append(nodes[name]) + return { + nodes[name]: writers[buffer] + for name, buffer in signature.inputs_to_buffers.items() + if buffer in writers + and nodes[name].users + and not any(writer.name in returned for writer in writers[buffer]) + } + def ops_to_not_decompose( self, ep: ExportedProgram ) -> Tuple[List[torch._ops.OpOverload], Optional[Callable[[torch.fx.Node], bool]]]: + # to_edge_transform_and_lower asks twice in a row for the same program, + # and the dry run in _drop_ops_left_outside can decompose all of it. + cached = self._ops_to_not_decompose_cache + if cached is not None and cached[0]() is ep: + return list(cached[1]), None do_not_decompose = [] op_support = _OperatorsSupportedForCoreMLBackend( self.skip_ops_for_coreml_delegation, @@ -635,21 +1251,24 @@ def ops_to_not_decompose( torch.ops.aten.repeat_interleave.self_int, torch.ops.aten.repeat_interleave.self_Tensor, ] + + def is_supported(submodules, node: torch.fx.Node) -> bool: + try: + return op_support.is_node_supported(submodules, node) + except Exception as e: + # CoreML's op_support.is_node_supported will sometimes throw + # for unsupported ops, rather than returning False + self.log_once( + f"Encountered exception when checking node support, treating node as unsupported: {e}" + ) + return False + rejected = set() for node in ep.graph.nodes: if node.op == "call_function" and isinstance( node.target, torch._ops.OpOverload ): - try: - supported = op_support.is_node_supported(None, node) - except Exception as e: - # CoreML's op_support.is_node_supported will sometimes throw - # for unsupported ops, rather than returning False - self.log_once( - f"Encountered exception when checking node support, treating node as unsupported: {e}" - ) - supported = False - if not supported: + if not is_supported(None, node): rejected.add(node.target) elif node.target not in do_not_decompose_blocklist and not _is_view_op( node.target @@ -657,4 +1276,117 @@ def ops_to_not_decompose( do_not_decompose.append(node.target) # Without a per-node filter, a preserved op stays whole at every call, so # one rejected call would be left with no kernel. - return [op for op in do_not_decompose if op not in rejected], None + keep = [op for op in do_not_decompose if op not in rejected] + keep = self._drop_ops_left_outside(ep, keep, rejected, is_supported) + # A weak reference, so the partitioner does not keep the program alive. + self._ops_to_not_decompose_cache = (weakref.ref(ep), keep) + return list(keep), None + + def _drop_ops_left_outside( + self, + ep: ExportedProgram, + keep: List[torch._ops.OpOverload], + rejected: Set[torch._ops.OpOverload], + is_supported: Callable[[Any, torch.fx.Node], bool], + ) -> List[torch._ops.OpOverload]: + """Drops every kept op that partition() moves out of CoreML at some + call, for a value CoreML cannot take or return, or that CoreML rejects + once the program is decomposed, since the portable kernels may have no + kernel for it. An op skipped by name stays kept. + + partition() sees the program once lowering decomposes the ops that are + not kept, which can make new calls of a kept op that CoreML rejects, + such as the matmul of a bfloat16 linear, or change the calls it moves + out. So when that may matter, this decomposes the program the same way + and finds the calls partition() leaves out, again after each drop that + changes the decomposition. It does not see transform passes, or the + ops an earlier partitioner takes. + """ + states = self._state_writers(ep) + kept_inside = self._placeholders_kept_inside(ep, states) + skip = set(self.skip_ops_for_coreml_delegation) + support = create_op_support( + lambda submodules, node: is_supported(submodules, node) + and not (skip and _edge_op_names(node.target) & skip) + ) + from_kept_inside = _computed_from_kept_inside(ep.graph_module, kept_inside) + may_leave_out = bool( + _find_ops_partitioning_may_leave_out( + ep.graph_module, + kept_inside, + _dynamic_size_names(ep), + lambda node: support.is_node_supported(None, node), + {state.name for state in states}, + ) + ) + not_kept = set(rejected) + decompositions = _default_decomposition_table() + while keep: + table = dict(decompositions) + for op in keep: + table.pop(op, None) + kept = set(keep) + decomposed = _decomposing_may_leave_out( + ep.graph_module, + table, + not_kept, + kept, + from_kept_inside, + _dynamic_size_names(ep), + lambda node: is_supported(None, node), + may_leave_out, + self.log_once, + ) + program = ep.run_decompositions(table) if decomposed else ep + graph_module = program.graph_module + sizes = _dynamic_size_names(program) + state_writers = self._state_writers(program) + calls = [node for node in graph_module.graph.nodes if node.target in kept] + left_out = _find_ops_left_out_of_any_partition( + graph_module, + support, + kept_inside, + sizes, + {writer for writers in state_writers.values() for writer in writers}, + ) + # The search is slow on a large graph, so run it only when it may + # leave out a call of an op lowering would otherwise decompose. + # Lowering keeps any other op whole either way, so whether such an + # op is kept changes nothing. + uncertain = _find_ops_partitioning_may_leave_out( + graph_module, + kept_inside, + sizes, + lambda node: support.is_node_supported(None, node), + {state.name for state in state_writers}, + ).difference(left_out) + if any( + node.target in decompositions and node in uncertain for node in calls + ): + _, left_out = _propose_partitions( + graph_module, + support, + kept_inside, + sizes, + self._targets_ios18(), + state_writers, + ) + else: + left_out |= uncertain + late = { + node.target + for node in calls + if node in left_out or (decomposed and not is_supported(None, node)) + } + if not late: + break + for op in late & decompositions.keys(): + self.log_once( + f"Not keeping {op} whole, because some call of it runs outside " + "of CoreML." + ) + not_kept |= late + keep = [op for op in keep if op not in late] + if not any(op in decompositions for op in late): + break + return keep diff --git a/backends/apple/coreml/test/test_coreml_partitioner.py b/backends/apple/coreml/test/test_coreml_partitioner.py index 6066299bca3..03350d27034 100644 --- a/backends/apple/coreml/test/test_coreml_partitioner.py +++ b/backends/apple/coreml/test/test_coreml_partitioner.py @@ -7,6 +7,7 @@ import logging import sys import unittest +from unittest import mock import coremltools as ct @@ -16,11 +17,15 @@ import torchvision from executorch.backends.apple.coreml.compiler import CoreMLBackend -from executorch.backends.apple.coreml.partition import CoreMLPartitioner +from executorch.backends.apple.coreml.partition import ( + coreml_partitioner, + CoreMLPartitioner, +) from executorch.backends.apple.coreml.test.test_coreml_utils import ( IS_VALID_TEST_RUNTIME, ) from executorch.exir.backend.utils import format_delegated_graph +from executorch.exir.passes.init_mutable_pass import InitializedMutableBufferPass @torch.library.custom_op("unsupported::linear", mutates_args=()) @@ -53,6 +58,20 @@ def is_fbcode(): class TestCoreMLPartitioner(unittest.TestCase): edge_compile_config = executorch.exir.EdgeCompileConfig() + def _lower_with_one_search(self, ep, partitioner): + """Lowers ep, checking that partitions are searched for once, since + each search is slow on a large model.""" + with mock.patch.object( + coreml_partitioner, + "CapabilityBasedPartitioner", + wraps=coreml_partitioner.CapabilityBasedPartitioner, + ) as search: + edge = executorch.exir.to_edge_transform_and_lower( + ep, partitioner=[partitioner] + ) + self.assertEqual(search.call_count, 1) + return edge + def test_add_sub_skip_mm(self): class Model(torch.nn.Module): def forward(self, a, x, b): @@ -805,6 +824,985 @@ def forward(self, x): self.assertEqual(len(sums), 2) self.assertTrue(all("delegation_tag" in n.meta for n in sums)) + def test_op_made_by_decomposing_a_rejected_op_is_not_kept_whole(self): + """ + A float32 matmul keeps aten.matmul whole. A bfloat16 linear is + rejected and decomposes into another matmul, which Core ML rejects too. + That matmul must be decomposed as well, since the portable kernels + have no matmul, or the program fails to load. + """ + + class Model(torch.nn.Module): + def __init__(self): + super().__init__() + self.proj = torch.nn.Linear(8, 8, bias=False).to(torch.bfloat16) + self.register_buffer("inv_freq", torch.rand(1, 4, 1)) + + def forward(self, x, pos): + freqs = self.inv_freq @ pos[:, None, :].float() + return self.proj(x).float(), freqs.cos() + + model = Model().eval() + inputs = (torch.rand(1, 6, 8, dtype=torch.bfloat16), torch.arange(6)[None]) + ep = torch.export.export(model, inputs, strict=True) + edge = executorch.exir.to_edge_transform_and_lower( + ep, partitioner=[CoreMLPartitioner()] + ) + op_names = [ + n.target.__name__ + for n in edge.exported_program().graph.nodes + if n.op == "call_function" + ] + self.assertIn("executorch_call_delegate", op_names) + self.assertNotIn("aten.matmul.default", op_names) + if _TEST_RUNTIME: + program = Runtime.get().load_program(edge.to_executorch().buffer) + outputs = program.load_method("forward").execute(inputs) + for out, ref in zip(outputs, model(*inputs)): + self.assertTrue(torch.allclose(out, ref, atol=1e-2)) + + def test_int8_output(self): + """ + The Core ML delegate has no int8 data type to hand an int8 tensor back + to ExecuTorch. So an op that makes one that is read outside Core ML, + here the model output, runs on the CPU. So does an op that hands one + out through a getitem, which always stays with the op. + """ + + class Model(torch.nn.Module): + def forward(self, x): + return (x * 2).to(torch.int8) + + class Split(torch.nn.Module): + def forward(self, x): + return (x * 2).to(torch.int8).split(2, dim=1)[0] + + # The cast to int8 reads a uint8 tensor that Core ML makes, which is no + # input from outside, so the error must not name it. + class FromUint8(torch.nn.Module): + def forward(self, x): + return (x.abs() * 2).to(torch.uint8).to(torch.int8) + + # Small integers, which Core ML's float16 math keeps exact. + x = torch.randint(-50, 50, (1, 4, 4, 4)).float() + for model in (Model().eval(), Split().eval(), FromUint8().eval()): + with self.subTest(model=type(model).__name__): + ep = torch.export.export(model, (x,), strict=True) + edge = self._lower_with_one_search(ep, CoreMLPartitioner()) + op_names = [ + n.target.__name__ + for n in edge.exported_program().graph.nodes + if n.op == "call_function" + ] + self.assertIn("executorch_call_delegate", op_names) + self.assertIn("dim_order_ops._to_dim_order_copy.default", op_names) + buffer = edge.to_executorch().buffer + if _TEST_RUNTIME: + program = Runtime.get().load_program(buffer) + out = program.load_method("forward").execute((x,))[0] + self.assertTrue(torch.equal(out, model(x))) + with self.assertRaisesRegex( + NotImplementedError, "makes an int8" + ) as error: + executorch.exir.to_edge_transform_and_lower( + ep, partitioner=[CoreMLPartitioner(lower_full_graph=True)] + ) + self.assertNotIn("cannot take as an input", str(error.exception)) + + # Core ML makes this int8 tensor in one partition and reads it in + # another, past the asinh, which runs on the CPU, so the cast to int8 + # runs on the CPU too. + class AcrossPartitions(torch.nn.Module): + def forward(self, x): + a = (x * 2).to(torch.int8) + return a.to(torch.int32).float() * torch.asinh(a.float()) + + model = AcrossPartitions().eval() + x = torch.randint(-20, 20, (2, 4)).float() + ep = torch.export.export(model, (x,), strict=True) + edge = executorch.exir.to_edge_transform_and_lower( + ep, partitioner=[CoreMLPartitioner()] + ) + delegate_outputs = [ + n.meta["val"].dtype + for n in edge.exported_program().graph.nodes + if n.op == "call_function" and n.target.__name__ == "getitem" + ] + self.assertTrue(delegate_outputs) + self.assertNotIn(torch.int8, delegate_outputs) + buffer = edge.to_executorch().buffer + if _TEST_RUNTIME: + program = Runtime.get().load_program(buffer) + out = program.load_method("forward").execute((x,))[0] + self.assertTrue(torch.allclose(out, model(x), atol=1e-2, rtol=1e-3)) + + # Here the cast to int8 reads a uint8 tensor from another partition, + # and only ops on the CPU read its int8, so the log names that tensor. + class Uint8AcrossPartitions(torch.nn.Module): + def forward(self, x): + t = (x.abs() * 2).to(torch.uint8) + return t.to(torch.int8).to(torch.int32).float() * torch.asinh(t.float()) + + # It names that tensor too when the op that reads it, here maximum, + # reads an int8 tensor as well, made on the CPU only because maximum + # runs there. + class Int8MadeForTheReader(torch.nn.Module): + def forward(self, x): + t = (x.abs() * 2).to(torch.uint8) + q = (x * 2).to(torch.int8) + return torch.maximum(q, t).float() * torch.asinh(t.float()) + + for model in (Uint8AcrossPartitions().eval(), Int8MadeForTheReader().eval()): + with self.subTest(model=type(model).__name__): + ep = torch.export.export(model, (x,), strict=True) + with self.assertLogs(coreml_partitioner.logger, logging.INFO) as logs: + executorch.exir.to_edge_transform_and_lower( + ep, partitioner=[CoreMLPartitioner()] + ) + self.assertIn("cannot take as an input", "\n".join(logs.output)) + + def test_int8_state(self): + """ + Writing int8 state that Core ML keeps is no int8 output: Core ML keeps + the state as float16 state, so the whole model stays in Core ML. + When the partitioner does not take state over, Core ML cannot read the + int8 state, so the cast and the add that read it run on the CPU, with + the cast that makes the int8 tensor the add reads. When a CPU op reads + the state, or no op does, it stays in the program, and writing it is an + int8 output, so the cast that makes its new value runs on the CPU. + """ + + class Kept(torch.nn.Module): + def __init__(self): + super().__init__() + self.register_buffer( + "state", torch.tensor([[1, -2, 3, -4]], dtype=torch.int8) + ) + + def forward(self, x): + y = self.state.float() * x + self.state.add_((x > 0).to(torch.int8)) + return y + + class KeptFromZero(Kept): + # The Core ML delegate zeroes the state it holds when it loads. + def __init__(self): + super().__init__() + self.state.zero_() + + class NeverRead(Kept): + def forward(self, x): + self.state.copy_((x * 4).to(torch.int8)) + return x + 1 + + class ReadOnCpu(Kept): + def forward(self, x): + y = torch.asinh(self.state) * x + self.state.copy_((x > 0).to(torch.int8)) + return y + + x = torch.tensor([[0.3, -0.2, 0.4, -0.1]]) + compile_specs = CoreMLBackend.generate_compile_specs( + minimum_deployment_target=ct.target.iOS18 + ) + cast = "dim_order_ops._to_dim_order_copy.default" + for model_class, take_over, cpu_ops in ( + (KeptFromZero, True, set()), + (Kept, False, {cast, "aten.add.Tensor"}), + (ReadOnCpu, True, {cast, "aten.asinh.default"}), + (NeverRead, True, {cast}), + ): + with self.subTest(model=model_class.__name__, take_over=take_over): + ep = torch.export.export(model_class().eval(), (x,), strict=True) + partitioner = CoreMLPartitioner( + compile_specs=compile_specs, take_over_mutable_buffer=take_over + ) + edge = executorch.exir.to_edge_transform_and_lower( + ep, partitioner=[partitioner] + ) + op_names = { + n.target.__name__ + for n in edge.exported_program().graph.nodes + if n.op == "call_function" + } + self.assertEqual( + op_names - {"executorch_call_delegate", "getitem"}, cpu_ops + ) + # Mutable buffers start uninitialized without this pass. + config = executorch.exir.ExecutorchBackendConfig( + passes=[InitializedMutableBufferPass(["state"])] + ) + buffer = edge.to_executorch(config).buffer + if _TEST_RUNTIME: + method = Runtime.get().load_program(buffer).load_method("forward") + eager = model_class().eval() + for _ in range(2): + out = method.execute((x,))[0] + self.assertTrue(torch.allclose(out, eager(x), atol=1e-2)) + + def test_int8_input_is_read_outside_the_delegate(self): + """ + Core ML cannot take a uint8 or int8 input, so the ops that read an int8 + image stay on the CPU up to its conversion to float, and the float ops + after it are still delegated. With lower_full_graph=True, lowering + fails and names the input, but not a uint8 tensor that Core ML makes + and an op on the CPU reads. + """ + + class Model(torch.nn.Module): + def forward(self, image): + return torch.relu(image.permute(2, 0, 1).float() / 127.0 - 0.25) + + model = Model().eval() + x = torch.randint(-128, 128, (8, 8, 3), dtype=torch.int8) + ep = torch.export.export(model, (x,), strict=True) + edge = executorch.exir.to_edge_transform_and_lower( + ep, partitioner=[CoreMLPartitioner()] + ) + op_names = [ + n.target.__name__ + for n in edge.exported_program().graph.nodes + if n.op == "call_function" + ] + self.assertIn("aten.permute_copy.default", op_names) + self.assertIn("dim_order_ops._to_dim_order_copy.default", op_names) + self.assertIn("executorch_call_delegate", op_names) + for name in ("aten.div.Tensor", "aten.sub.Tensor", "aten.relu.default"): + self.assertNotIn(name, op_names) + buffer = edge.to_executorch().buffer + if _TEST_RUNTIME: + program = Runtime.get().load_program(buffer) + out = program.load_method("forward").execute((x,))[0] + self.assertTrue(torch.allclose(out, model(x), atol=1e-2)) + with self.assertRaisesRegex(NotImplementedError, "image \\(torch\\.int8, "): + executorch.exir.to_edge_transform_and_lower( + ep, partitioner=[CoreMLPartitioner(lower_full_graph=True)] + ) + + class Mixed(torch.nn.Module): + def forward(self, image, x): + return (image + (x * 255).to(torch.uint8)).float() * 2 + + class Chain(torch.nn.Module): + def forward(self, image, x): + return ((image + 1) + (x * 255).to(torch.uint8)).float() * 2 + + inputs = (torch.randint(0, 100, (2, 4), dtype=torch.uint8), torch.rand(2, 4)) + for model in (Mixed(), Chain()): + with self.subTest(model=type(model).__name__): + ep = torch.export.export(model.eval(), inputs, strict=True) + with self.assertRaises(NotImplementedError) as error: + executorch.exir.to_edge_transform_and_lower( + ep, partitioner=[CoreMLPartitioner(lower_full_graph=True)] + ) + self.assertIn( + "partition: image (torch.uint8, (2, 4)).", str(error.exception) + ) + + def test_uint8_input_read_by_an_op_without_a_cpu_kernel(self): + """ + A reflect pad that reads a uint8 input runs on the CPU. Core ML keeps + pad whole, but pad is not a core ATen op and has no portable kernel, so + it is decomposed there. Below iOS 18 the index math it decomposes into + runs on the CPU too, since it would leave Core ML a partition with no + inputs. So does a pad that would be such a partition itself, also + once lowering decomposes the ops before it. + """ + + class Model(torch.nn.Module): + def forward(self, image): + image = torch.nn.functional.pad(image, (1, 1, 1, 1), mode="reflect") + return image.float() / 255.0 + + class PadOfBuffer(torch.nn.Module): + def __init__(self): + super().__init__() + self.register_buffer("w", torch.rand(1, 3, 8)) + + def forward(self, image): + p = torch.nn.functional.pad(self.w, (1, 1), mode="reflect") + return torch.asinh(p) + image.float() + + class PadOfFilledBuffer(PadOfBuffer): + def __init__(self): + super().__init__() + # One entry: Core ML refuses an index_put with two entries next to + # a slice. + self.register_buffer("cols", torch.tensor([2])) + + def forward(self, image): + w = self.w.index_fill(2, self.cols, 0.0) + p = torch.nn.functional.pad(w, (1, 1), mode="reflect") + return torch.asinh(p) + image.float() + + image = torch.randint(0, 256, (1, 3, 10), dtype=torch.uint8) + for model, x in ( + (Model(), torch.randint(0, 256, (1, 3, 8, 8), dtype=torch.uint8)), + # The pad reads only a buffer Core ML takes over, and the asinh + # after it runs on the CPU, so it would be a partition with no + # inputs. + (PadOfBuffer(), image), + # Core ML rejects index_fill, so the pad reads only buffers once + # lowering decomposes it, into a scalar_tensor and an index_put. + (PadOfFilledBuffer(), image), + ): + with self.subTest(model=type(model).__name__): + ep = torch.export.export(model.eval(), (x,), strict=True) + edge = executorch.exir.to_edge_transform_and_lower( + ep, partitioner=[CoreMLPartitioner()] + ) + op_names = [ + n.target.__name__ + for n in edge.exported_program().graph.nodes + if n.op == "call_function" + ] + self.assertIn("aten.arange.start_step", op_names) + self.assertNotIn("aten.pad.default", op_names) + self.assertIn("executorch_call_delegate", op_names) + buffer = edge.to_executorch().buffer + if _TEST_RUNTIME: + program = Runtime.get().load_program(buffer) + out = program.load_method("forward").execute((x,))[0] + self.assertTrue(torch.allclose(out, model(x), atol=1e-2, rtol=1e-3)) + + def test_pad_of_buffer_after_a_decomposition_that_reads_a_size(self): + """ + channel_shuffle and repeat_interleave of a 5-D tensor with a dynamic + size decompose into a rank 6 view or expand, which runs on the CPU and + reads a size Core ML would compute, so that size moves to the CPU too. + Then, below iOS 18, the pad of a buffer is a partition without inputs, + since the asinh after it runs on the CPU, so it moves out. Only the + decomposed program shows this, so the program is decomposed to find + it, and pad is decomposed too. A uint8 value made and read inside Core + ML changes nothing. + """ + + class Model(torch.nn.Module): + def __init__(self, op): + super().__init__() + self.op = op + self.register_buffer("w", torch.rand(1, 3, 8)) + + def forward(self, x): + p = torch.nn.functional.pad(self.w, (1, 1), mode="reflect") + x = x + torch.asinh(p).mean() + if self.op == "uint8": + x = x + (x * 0).to(torch.uint8).float() + if self.op == "repeat_interleave": + return x.repeat_interleave(2, dim=1) + return torch.nn.functional.channel_shuffle(x, 2) + + x = torch.rand(1, 4, 2, 4, 6) + for op in ("channel_shuffle", "uint8", "repeat_interleave"): + with self.subTest(op=op): + model = Model(op).eval() + ep = torch.export.export( + model, + (x,), + dynamic_shapes=({3: torch.export.Dim("height", min=2, max=32)},), + strict=True, + ) + edge = executorch.exir.to_edge_transform_and_lower( + ep, partitioner=[CoreMLPartitioner()] + ) + op_names = [ + n.target.__name__ + for n in edge.exported_program().graph.nodes + if n.op == "call_function" + ] + self.assertNotIn("aten.pad.default", op_names) + self.assertIn("executorch_call_delegate", op_names) + buffer = edge.to_executorch().buffer + if _TEST_RUNTIME: + program = Runtime.get().load_program(buffer) + out = program.load_method("forward").execute((x,))[0] + self.assertTrue(torch.allclose(out, model(x), atol=1e-2, rtol=1e-3)) + + def test_pad_of_buffer_next_to_uint8_input_stays_whole(self): + """ + A pad that reads only a buffer Core ML takes over would be a partition + without inputs if the ops after it moved to the CPU. In a model with a + uint8 input some ops do move, so the search for partitions decides + whether pad stays whole. Here the partition of the pad also reads x, + so pad stays whole. + """ + + class Model(torch.nn.Module): + def __init__(self): + super().__init__() + self.register_buffer("w", torch.rand(1, 3, 4)) + + def forward(self, image, x): + return image.float() + torch.nn.functional.pad(self.w, (1, 1)) * x + + model = Model().eval() + inputs = ( + torch.randint(0, 256, (1, 3, 6), dtype=torch.uint8), + torch.rand(1, 3, 6), + ) + ep = torch.export.export(model, inputs, strict=True) + keep, _ = CoreMLPartitioner().ops_to_not_decompose(ep) + self.assertIn(torch.ops.aten.pad.default, keep) + edge = executorch.exir.to_edge_transform_and_lower( + ep, partitioner=[CoreMLPartitioner()] + ) + buffer = edge.to_executorch().buffer + if _TEST_RUNTIME: + program = Runtime.get().load_program(buffer) + out = program.load_method("forward").execute(inputs)[0] + self.assertTrue(torch.allclose(out, model(*inputs), atol=1e-2, rtol=1e-3)) + + def test_uint8_input_with_dynamic_shape(self): + """ + With a dynamic height, the index math of a decomposed roll reads only + a symbolic size, which Core ML cannot take as an input, so it runs on + the CPU too, also from iOS 18, where a partition with no inputs at all + may stay in Core ML. So do a reshape to a size read from the uint8 + input, a scale computed from it, and the add after the reshape, since + Core ML cannot take a tensor sized by an expression of the height, such + as 3 * height, as an input. + """ + + class Model(torch.nn.Module): + def forward(self, image): + rolled = torch.roll(image, 1, 2).float() / 255.0 + scaled = rolled * (image.shape[2] / 8) + return scaled.reshape(1, 3 * image.shape[2], 8) + 1 + + model = Model().eval() + x = torch.randint(0, 256, (1, 3, 8, 8), dtype=torch.uint8) + ep = torch.export.export( + model, + (x,), + dynamic_shapes=({2: torch.export.Dim("height", min=4, max=64)},), + strict=True, + ) + compile_specs = CoreMLBackend.generate_compile_specs( + minimum_deployment_target=ct.target.iOS18 + ) + edge = self._lower_with_one_search( + ep, CoreMLPartitioner(compile_specs=compile_specs) + ) + op_names = [ + n.target.__name__ + for n in edge.exported_program().graph.nodes + if n.op == "call_function" + ] + for name in ( + "aten.arange.start_step", + "aten.view_copy.default", + "aten.add.Tensor", + ): + self.assertIn(name, op_names) + self.assertIn("executorch_call_delegate", op_names) + buffer = edge.to_executorch().buffer + if _TEST_RUNTIME: + y = torch.randint(0, 256, (1, 3, 12, 8), dtype=torch.uint8) + program = Runtime.get().load_program(buffer) + out = program.load_method("forward").execute((y,))[0] + self.assertTrue(torch.allclose(out, model(y), atol=1e-2)) + + def test_symbolic_value_read_outside_the_delegate(self): + """ + The Core ML delegate returns only tensors, so the item of a flag, a + symbolic value that sym_ite reads on the CPU, runs on the CPU too, + below and from iOS 18, where a partition with no inputs at all may + stay in Core ML. So does a size the model returns, and with + lower_full_graph=True lowering fails and names it. The reshape to a + size computed from it runs on the CPU too, and so does the op after it, + since Core ML cannot take a tensor sized by an expression of a dynamic + size, such as 3 * rows, as an input. The other ops are still + delegated. + """ + + class Model(torch.nn.Module): + def __init__(self): + super().__init__() + self.register_buffer("flag", torch.tensor(True)) + + def forward(self, x): + k = torch.sym_ite(self.flag.item(), 2, 3) + return (torch.relu(x) + 1) * k + + model = Model().eval() + x = torch.rand(2, 3) + for target in (ct.target.iOS15, ct.target.iOS18): + with self.subTest(target=target): + ep = torch.export.export(model, (x,), strict=False) + compile_specs = CoreMLBackend.generate_compile_specs( + minimum_deployment_target=target + ) + edge = self._lower_with_one_search( + ep, CoreMLPartitioner(compile_specs=compile_specs) + ) + op_names = [ + n.target.__name__ + for n in edge.exported_program().graph.nodes + if n.op == "call_function" + ] + self.assertIn("_local_scalar_dense.default", op_names) + self.assertIn("executorch_call_delegate", op_names) + for name in ("aten.relu.default", "aten.add.Tensor", "aten.mul.Tensor"): + self.assertNotIn(name, op_names) + buffer = edge.to_executorch().buffer + if _TEST_RUNTIME: + program = Runtime.get().load_program(buffer) + out = program.load_method("forward").execute((x,))[0] + self.assertTrue(torch.allclose(out, model(x), atol=1e-2)) + + class ReturnsSize(torch.nn.Module): + def forward(self, x): + rows = x.shape[0] + return (torch.relu(x) + 1).reshape(1, rows * 3) * 2, rows + + ep = torch.export.export( + ReturnsSize().eval(), + (x,), + dynamic_shapes=({0: torch.export.Dim("rows", min=2, max=8)},), + strict=True, + ) + edge = self._lower_with_one_search(ep, CoreMLPartitioner()) + op_names = [ + n.target.__name__ + for n in edge.exported_program().graph.nodes + if n.op == "call_function" + ] + for name in ("sym_size.int", "aten.view_copy.default", "aten.mul.Tensor"): + self.assertIn(name, op_names) + for name in ("aten.relu.default", "aten.add.Tensor"): + self.assertNotIn(name, op_names) + self.assertIn("executorch_call_delegate", op_names) + buffer = edge.to_executorch().buffer + if _TEST_RUNTIME: + y = torch.rand(5, 3) + program = Runtime.get().load_program(buffer) + out, rows = program.load_method("forward").execute((y,)) + expected = (torch.relu(y) + 1).reshape(1, 15) * 2 + self.assertTrue(torch.allclose(out, expected, atol=1e-2)) + self.assertEqual(rows, 5) + with self.assertRaisesRegex(NotImplementedError, "symbolic value.*sym_size"): + executorch.exir.to_edge_transform_and_lower( + ep, partitioner=[CoreMLPartitioner(lower_full_graph=True)] + ) + + # The size of a uint8 input runs on the CPU only because it reads that + # input, so the error names the input and not the size. + class SizeOfUint8(torch.nn.Module): + def forward(self, image): + return image.float().reshape(image.shape[0], -1) * 2 + + ep = torch.export.export( + SizeOfUint8().eval(), + (torch.randint(0, 100, (4, 6), dtype=torch.uint8),), + dynamic_shapes=({0: torch.export.Dim("rows", min=2, max=8)},), + strict=True, + ) + with self.assertRaises(NotImplementedError) as error: + executorch.exir.to_edge_transform_and_lower( + ep, partitioner=[CoreMLPartitioner(lower_full_graph=True)] + ) + self.assertIn("image (torch.uint8, ", str(error.exception)) + self.assertNotIn("sym_size", str(error.exception)) + + # Partitions on both sides of the asinh, which Core ML rejects, read the + # same size, so it runs on the CPU, and so do the reshapes, which the + # log explains with the symbolic value they read. + class SharedSize(torch.nn.Module): + def forward(self, x): + rows = x.shape[0] + return torch.asinh((x * 2).reshape(rows, 2, 2)).reshape(rows, 4) + 1 + + ep = torch.export.export( + SharedSize().eval(), + (torch.rand(4, 4),), + dynamic_shapes=({0: torch.export.Dim("rows", min=2, max=8)},), + strict=True, + ) + with self.assertLogs(coreml_partitioner.logger, logging.INFO) as logs: + executorch.exir.to_edge_transform_and_lower( + ep, partitioner=[CoreMLPartitioner()] + ) + self.assertIn("makes or reads a symbolic value", "\n".join(logs.output)) + + def test_input_sized_by_a_declared_expression_stays_in_the_delegate(self): + """ + coremltools takes an input sized by an expression of a dynamic size + when the exported program declares it, such as 2 * n here, so the ops + that read it stay in Core ML. + """ + + class Model(torch.nn.Module): + def forward(self, x, y): + return x.reshape(1, -1, 2).sum(-1) + y + + size = torch.export.Dim("n", min=2, max=32) + ep = torch.export.export( + Model().eval(), + (torch.rand(1, 8), torch.rand(1, 4)), + dynamic_shapes={"x": {1: 2 * size}, "y": {1: size}}, + strict=True, + ) + edge = executorch.exir.to_edge_transform_and_lower( + ep, partitioner=[CoreMLPartitioner()] + ) + op_names = { + n.target.__name__ + for n in edge.exported_program().graph.nodes + if n.op == "call_function" + } + self.assertEqual(op_names, {"executorch_call_delegate", "getitem"}) + buffer = edge.to_executorch().buffer + if _TEST_RUNTIME: + x, y = torch.rand(1, 12), torch.rand(1, 6) + program = Runtime.get().load_program(buffer) + out = program.load_method("forward").execute((x, y))[0] + self.assertTrue(torch.allclose(out, Model()(x, y), atol=1e-2)) + + def test_program_is_decomposed_only_when_it_may_matter(self): + """ + Finding the ops partition() runs on the CPU decomposes the whole + program, as lowering will, only when that may change the answer, since + that takes minutes on a large model. Here index_fill, which Core ML + rejects, decomposes into ops the program does not keep. It reads only + buffers, so its decomposition could make a partition without inputs, + but such a partition moves out only once some other op does, and no op + does here. The item of a buffer runs on the CPU, since the model + returns its value, but it makes no tensor, so decomposing it leaves + every partition as it is. + """ + + class Fill(torch.nn.Module): + def __init__(self): + super().__init__() + self.register_buffer("table", torch.rand(2, 4)) + self.register_buffer("cols", torch.tensor([0, 2])) + + def forward(self, x): + return self.table.index_fill(1, self.cols, 0.0) + x + + class ItemOfBuffer(torch.nn.Module): + def __init__(self): + super().__init__() + self.register_buffer("n", torch.tensor(3)) + + def forward(self, x): + return x + 1, self.n.item() + + decompose = torch.export.ExportedProgram.run_decompositions + for model in (Fill(), ItemOfBuffer()): + with self.subTest(model=type(model).__name__): + ep = torch.export.export(model.eval(), (torch.rand(2, 4),), strict=True) + with mock.patch.object( + torch.export.ExportedProgram, + "run_decompositions", + autospec=True, + side_effect=decompose, + ) as run_decompositions: + keep, _ = CoreMLPartitioner().ops_to_not_decompose(ep) + self.assertIn(torch.ops.aten.add.Tensor, keep) + self.assertEqual(run_decompositions.call_count, 0) + + def test_skip_names_in_a_program_as_exported(self): + """ + ops_to_not_decompose can take a program as exported, which can still + have a view op that has no copy, such as reshape. Lowering turns it + into views first, so a skip name never names it. + """ + + class Model(torch.nn.Module): + def forward(self, x): + return x.reshape(2, -1) * 2 + + ep = torch.export.export(Model().eval(), (torch.rand(2, 4),), strict=True) + keep, _ = CoreMLPartitioner().ops_to_not_decompose(ep) + partitioner = CoreMLPartitioner( + skip_ops_for_coreml_delegation=["aten.mul.tensor"] + ) + self.assertEqual(partitioner.ops_to_not_decompose(ep)[0], keep) + + def test_uint8_input_read_by_a_kept_op_once_decomposed(self): + """ + isposinf of an int tensor decomposes into zeros_like, and the portable + kernels have no zeros_like. The zeros_like that Core ML runs keeps + zeros_like whole, so the one that isposinf of a uint8 input decomposes + into would run whole on the CPU. Tracing the decomposition of + isposinf by itself shows that it reads the uint8 input, so the program + is decomposed to find that call, and zeros_like is decomposed too. + """ + + class Model(torch.nn.Module): + def forward(self, image, x): + return torch.zeros_like(x) + x, torch.isposinf(image) + + model = Model().eval() + inputs = ( + torch.randint(0, 256, (1, 3, 4), dtype=torch.uint8), + torch.rand(1, 3, 4), + ) + ep = torch.export.export(model, inputs, strict=True) + edge = executorch.exir.to_edge_transform_and_lower( + ep, partitioner=[CoreMLPartitioner()] + ) + op_names = [ + n.target.__name__ + for n in edge.exported_program().graph.nodes + if n.op == "call_function" + ] + self.assertIn("executorch_call_delegate", op_names) + self.assertNotIn("aten.zeros_like.default", op_names) + buffer = edge.to_executorch().buffer + if _TEST_RUNTIME: + program = Runtime.get().load_program(buffer) + outputs = program.load_method("forward").execute(inputs) + for out, ref in zip(outputs, model(*inputs)): + self.assertTrue(torch.allclose(out.float(), ref.float(), atol=1e-2)) + + def test_uint8_input_with_state(self): + """ + A CPU op that reads a uint8 input can sit between the Core ML ops that + read and write state, or make its new value. Such state stays outside + of Core ML, which reads it as an input. + """ + + class Split(torch.nn.Module): + def __init__(self): + super().__init__() + self.register_buffer("state", torch.zeros(1, 3, 2, 2)) + + def forward(self, image): + y = (image + (self.state * 2).to(torch.uint8)).float() + self.state.add_(y) + return y + 1 + + class CopyIn(torch.nn.Module): + def __init__(self): + super().__init__() + self.register_buffer("state", torch.zeros(1, 3, 2, 2)) + + def forward(self, image): + y = self.state * 2 + 1 + self.state.copy_(image.float()) + return y + + x = torch.randint(0, 3, (1, 3, 2, 2), dtype=torch.uint8) + for model_class in (Split, CopyIn): + with self.subTest(model=model_class.__name__): + ep = torch.export.export(model_class().eval(), (x,), strict=True) + edge = executorch.exir.to_edge_transform_and_lower( + ep, partitioner=[CoreMLPartitioner()] + ) + op_names = [ + n.target.__name__ + for n in edge.exported_program().graph.nodes + if n.op == "call_function" + ] + # Below iOS 18 Core ML rejects a partition without inputs, but + # the state is an input here, so Core ML takes state * 2 too. + self.assertNotIn("aten.mul.Tensor", op_names) + self.assertIn( + "state", + edge.exported_program().graph_signature.buffers_to_mutate.values(), + ) + buffer = edge.to_executorch().buffer + if _TEST_RUNTIME: + method = Runtime.get().load_program(buffer).load_method("forward") + eager = model_class().eval() + for _ in range(2): + out = method.execute((x,))[0] + self.assertTrue(torch.allclose(out, eager(x), atol=1e-2)) + + def test_uint8_buffer_not_taken_over_is_read_outside_the_delegate(self): + """ + A uint8 buffer that Core ML does not take over is a program input, so + the op that reads it runs on the CPU. So is uint8 state that stays in + the program, because a CPU op reads it too, also through its new value, + or because the model returns its new value. A pad of such state runs on + the CPU decomposed, since the portable kernels have no pad. With + lower_full_graph=True, lowering fails and names that state. + """ + + class Model(torch.nn.Module): + def __init__(self): + super().__init__() + self.register_buffer("lut", torch.arange(0, 256, 32, dtype=torch.uint8)) + + def forward(self, x): + return x * (self.lut.float() / 255.0) + + class ReadOnCpu(torch.nn.Module): + def __init__(self): + super().__init__() + self.register_buffer( + "state", torch.tensor([[1, 2, 3, 4]], dtype=torch.uint8) + ) + + def forward(self, image, x): + y = torch.nn.functional.pad(self.state, (1, 1)).float() * x + y = y + (image + self.state).float().sum() + self.state.add_(1) + return y + + class NewValueOnCpu(ReadOnCpu): + def forward(self, x): + y = self.state.float() * x + self.state.copy_((x * 10).to(torch.uint8)) + return y, torch.diagonal(self.state).float() + + class Returned(ReadOnCpu): + def forward(self, x): + self.state.add_(1) + return self.state.float() * x, self.state + + state_inputs = ( + torch.randint(0, 3, (1, 4), dtype=torch.uint8), + torch.rand(1, 6), + ) + x = torch.rand(1, 4) + for model_class, inputs, partitioner in ( + ( + Model, + (torch.randn(2, 8),), + CoreMLPartitioner(take_over_constant_data=False), + ), + (ReadOnCpu, state_inputs, CoreMLPartitioner()), + (NewValueOnCpu, (x,), CoreMLPartitioner()), + (Returned, (x,), CoreMLPartitioner()), + ): + with self.subTest(model=model_class.__name__): + ep = torch.export.export(model_class().eval(), inputs, strict=True) + edge = executorch.exir.to_edge_transform_and_lower( + ep, partitioner=[partitioner] + ) + op_names = [ + n.target.__name__ + for n in edge.exported_program().graph.nodes + if n.op == "call_function" + ] + self.assertIn("dim_order_ops._to_dim_order_copy.default", op_names) + self.assertIn("executorch_call_delegate", op_names) + # Mutable buffers start uninitialized without this pass. + config = executorch.exir.ExecutorchBackendConfig( + passes=[InitializedMutableBufferPass(["state"])] + ) + buffer = edge.to_executorch(config).buffer + if _TEST_RUNTIME: + method = Runtime.get().load_program(buffer).load_method("forward") + eager = model_class().eval() + for _ in range(2): + outputs = method.execute(inputs) + refs = eager(*inputs) + refs = refs if isinstance(refs, tuple) else (refs,) + for out, ref in zip(outputs, refs, strict=True): + self.assertTrue( + torch.allclose( + out.float(), ref.float(), atol=1e-2, rtol=1e-3 + ) + ) + with self.assertRaisesRegex(NotImplementedError, "b_state \\(torch\\.uint8, "): + executorch.exir.to_edge_transform_and_lower( + torch.export.export(ReadOnCpu().eval(), state_inputs, strict=True), + partitioner=[CoreMLPartitioner(lower_full_graph=True)], + ) + + def test_uint8_made_inside_the_delegate_keeps_ops_whole(self): + """ + A uint8 tensor made inside the delegate is not an input, so the pad + that reads it stays in the delegate, whole. + """ + + class Model(torch.nn.Module): + def forward(self, x): + image = torch.nn.functional.pad((x * 255).to(torch.uint8), (1, 1)) + return torch.nn.functional.pad( + image.float() / 255.0, (1, 1), mode="reflect" + ) + + model = Model().eval() + x = torch.rand(1, 3, 8) + ep = torch.export.export(model, (x,), strict=True) + edge = executorch.exir.to_edge_transform_and_lower( + ep, partitioner=[CoreMLPartitioner()] + ) + op_names = { + n.target.__name__ + for n in edge.exported_program().graph.nodes + if n.op == "call_function" + } + self.assertEqual(op_names, {"executorch_call_delegate", "getitem"}) + buffer = edge.to_executorch().buffer + if _TEST_RUNTIME: + program = Runtime.get().load_program(buffer) + out = program.load_method("forward").execute((x,))[0] + self.assertTrue(torch.allclose(out, model(x), atol=1e-2)) + + def test_ops_after_a_cpu_op_on_a_tensor_made_inside(self): + """ + A uint8 tensor made in Core ML and passed through an op that stays on + the CPU, such as a skipped view or cast, or an add that reads it from + another partition past acosh, sends the pad after it to the CPU too. + The pad is decomposed there, since the portable kernels have no pad, + and so is a pad that makes an int8 model output. The log names the + uint8 tensors that ops read from the CPU or from another partition. + """ + + class FlipPad(torch.nn.Module): + def forward(self, x): + d = (x * 255).to(torch.uint8).flip(-1) + return torch.nn.functional.pad(d, (1, 1)).float() / 255.0 + + class ViewPad(torch.nn.Module): + def forward(self, x): + d = (x * 255).to(torch.uint8).view(1, 3, 8) + return torch.nn.functional.pad(d, (1, 1)).float() / 255.0 + + class AcoshPad(torch.nn.Module): + def forward(self, x): + d = (x * 255).to(torch.uint8) + d = d + torch.acosh(d.float() + 2).to(torch.uint8) + return torch.nn.functional.pad(d, (1, 1)).float() / 255.0 + + class PadInt8(torch.nn.Module): + def forward(self, x): + return torch.nn.functional.pad((x * 2).to(torch.int8), (1, 1)) + + for model, x, skip in ( + (ViewPad(), torch.rand(1, 3, 2, 4), ["aten.view_copy.default"]), + ( + FlipPad(), + torch.rand(1, 3, 8), + ["dim_order_ops._to_dim_order_copy.default"], + ), + (AcoshPad(), torch.rand(1, 3, 8), None), + (PadInt8(), torch.randint(-25, 25, (1, 3, 8)).float(), None), + ): + with self.subTest(model=type(model).__name__, skip=skip): + ep = torch.export.export(model.eval(), (x,), strict=True) + with self.assertLogs(coreml_partitioner.logger, logging.INFO) as logs: + edge = executorch.exir.to_edge_transform_and_lower( + ep, + partitioner=[ + CoreMLPartitioner(skip_ops_for_coreml_delegation=skip) + ], + ) + self.assertEqual( + "cannot take as an input" in "\n".join(logs.output), + not isinstance(model, PadInt8), + ) + op_names = [ + n.target.__name__ + for n in edge.exported_program().graph.nodes + if n.op == "call_function" + ] + self.assertIn("aten.constant_pad_nd.default", op_names) + self.assertIn("executorch_call_delegate", op_names) + buffer = edge.to_executorch().buffer + if _TEST_RUNTIME: + program = Runtime.get().load_program(buffer) + out = program.load_method("forward").execute((x,))[0] + self.assertTrue( + torch.allclose(out.float(), model(x).float(), atol=1e-2) + ) + def test_tied_embedding_is_quantized_with_its_linear(self): """A weight that is both an embedding table and an output projection lowers. diff --git a/backends/apple/coreml/test/test_torch_ops.py b/backends/apple/coreml/test/test_torch_ops.py index 39d64a3f01e..249942958e8 100644 --- a/backends/apple/coreml/test/test_torch_ops.py +++ b/backends/apple/coreml/test/test_torch_ops.py @@ -777,6 +777,43 @@ def forward(self, ids, x): [types.int32] * 4, ) + def test_fills_of_a_tensor_use_fill_like(self): + """ + A fill the shape of a tensor becomes a constant as big as that tensor + in the model when the shape is known at conversion, unless it is a + fill_like, which needs iOS 16, the target this test uses. + """ + + class Fills(torch.nn.Module): + def forward(self, x): + ones = torch.ones_like(x) + torch.zeros_like(x) + return ones + torch.full_like(x, 2) + x.clone().fill_(3) + + model = Fills().eval() + x = torch.rand(4, 64) + delegated_program = executorch.exir.to_edge_transform_and_lower( + torch.export.export(model, (x,)), + partitioner=[ + self._coreml_partitioner(minimum_deployment_target=ct.target.iOS16) + ], + ) + self.assertEqual( + [ + node.target.__name__ + for node in delegated_program.exported_program().graph.nodes + if node.op == "call_function" + ], + ["executorch_call_delegate", "getitem"], + ) + lowered = delegated_program.exported_program().graph_module.lowered_module_0 + program = ct.convert( + lowered.original_module, + convert_to="milinternal", + minimum_deployment_target=ct.target.iOS16, + ) + self.assertEqual(len(program.find_ops(op_type="fill_like")), 4) + self._compare_outputs(delegated_program.to_executorch(), model, (x,)) + @unittest.skipIf(is_fbcode(), "The coremltools in fbcode has no rms_norm.") def test_rms_norm_without_eps(self): """ diff --git a/docs/source/backends/coreml/coreml-partitioner.md b/docs/source/backends/coreml/coreml-partitioner.md index fa7a1de8277..9d5631b0903 100644 --- a/docs/source/backends/coreml/coreml-partitioner.md +++ b/docs/source/backends/coreml/coreml-partitioner.md @@ -6,8 +6,12 @@ The Core ML partitioner API allows for configuration of the model delegation to - `skip_ops_for_coreml_delegation`: Allows you to skip ops for delegation by Core ML. By default, all ops that Core ML supports will be delegated. See [here](https://github.com/pytorch/executorch/blob/14ff52ff89a89c074fc6c14d3f01683677783dcd/backends/apple/coreml/test/test_coreml_partitioner.py#L42) for an example of skipping an op for delegation. - `compile_specs`: A list of `CompileSpec`s for the Core ML backend. These control low-level details of Core ML delegation, such as the compute unit (CPU, GPU, ANE), the iOS deployment target, and the compute precision (FP16, FP32). These are discussed more below. - `take_over_mutable_buffer`: A boolean that indicates whether PyTorch mutable buffers in stateful models should be converted to [Core ML `MLState`](https://developer.apple.com/documentation/coreml/mlstate). If set to `False`, mutable buffers in the PyTorch graph are converted to graph inputs and outputs to the Core ML lowered module under the hood. Generally, setting `take_over_mutable_buffer` to true will result in better performance, but using `MLState` requires iOS >= 18.0, macOS >= 15.0, and Xcode >= 16.0. -- `take_over_constant_data`: A boolean that indicates whether PyTorch constant data like model weights should be consumed by the Core ML delegate. If set to False, constant data is passed to the Core ML delegate as inputs. By default, take_over_constant_data=True. -- `lower_full_graph`: A boolean that indicates whether the entire graph must be lowered to Core ML. If set to True and Core ML does not support an op, an error is raised during lowering. If set to False and Core ML does not support an op, the op is executed on the CPU by ExecuTorch. Although setting `lower_full_graph`=False can allow a model to lower where it would otherwise fail, it can introduce performance overhead in the model when there are unsupported ops. You will see warnings about unsupported ops during lowering if there are any. By default, `lower_full_graph`=False. +- `take_over_constant_data`: A boolean that indicates whether PyTorch constant data like model weights should be consumed by the Core ML delegate. If set to False, constant data is passed to the Core ML delegate as inputs. By default, take_over_constant_data=True. A uint8 buffer that the model casts or gathers from, such as a lookup table, fails to lower with the default settings, since coremltools cannot cast or gather from a uint8 constant. Storing such a table as float16 or int32 avoids that. take_over_constant_data=False also works, since the cast then runs on the CPU, but it passes every weight to Core ML as an input, and runs the dequantize of int8 or uint8 weights on the CPU. +- `lower_full_graph`: A boolean that indicates whether the entire graph must be lowered to Core ML. If set to True and Core ML does not support an op, or an op has to run on the CPU as described below, an error is raised during lowering. If set to False and Core ML does not support an op, the op is executed on the CPU by ExecuTorch. Although setting `lower_full_graph`=False can allow a model to lower where it would otherwise fail, it can introduce performance overhead in the model when there are unsupported ops. You will see warnings about unsupported ops during lowering if there are any. By default, `lower_full_graph`=False. + +The Core ML delegate cannot take a uint8 or int8 tensor as an input, so the ops that read one, such as the cast of a uint8 camera frame, run on the CPU. For camera frames, converting each frame with the `ImageProcessor` in [`extension/image`](https://github.com/pytorch/executorch/tree/main/extension/image) and exporting the model with a float input lets those ops run in Core ML too. + +A few other values cannot cross the edge of a Core ML part either, so the ops on the Core ML side of that edge run on the CPU. Core ML cannot take a symbolic value, such as a size known only at run time, as an input. It also cannot take a tensor sized by an expression of a dynamic size, such as `2 * height`, unless the model was exported with that expression as a derived `Dim`. And the delegate cannot return an int8 tensor or a symbolic value. #### Core ML CompileSpec From e2a22691e9681eae337a3facfa7abb8de5d92aa2 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Wed, 4 Nov 2026 14:52:02 -0800 Subject: [PATCH 069/128] CUDA backend: one current stream on Windows, shim copies during another thread's graph capture, and low-memory mode moves weights off the GPU while autotuning (#24597) ### What is wrong today **Windows stream state.** The CUDA delegate and its shims share a per-thread "current stream" map. In the default Windows build (`EXECUTORCH_BUILD_SHARED` off) it exists twice, in `aoti_cuda_shims.dll` (not exported) and in the program. The shims read the empty copy and make their own stream, so they race with generated kernels. On every platform, a thread with no stream selected also leaked a stream. **Copies during another thread's graph capture.** With no caller stream, a shim copy used `cudaMemcpy` on the legacy stream. CUDA rejects that during a capture on a blocking stream like the delegate's, so the process aborted. Reading back a strided output also failed. **Export memory.** AOTInductor's final autotuning pass keeps the real weights on the GPU, unused. A video policy needing about 60 GiB ran out of memory on an 80 GB GPU. ### What this change does - Move the current-stream map into the shared library `extension_cuda`, in an internal namespace. With no stream selected, use `cudaStreamPerThread`, the stream the delegate runs on. - Copy with `cudaMemcpyAsync` on the current stream, and read strided outputs back on the delegate's stream. - Still on the legacy stream, among others: the allocator's copy and `synchronize()`, mutable-state and load-time copies, and the capture step's seed copies. - In the existing `low_memory_mode`, move the weights to a file in PyTorch's cache directory during that pass. It needs PyTorch 2.15 or a nightly from 2026-09-24 or later; with older PyTorch the mode works as before. It needs free space there, and PyTorch skips the move when the last generated kernel runs on the CPU. ### Tested - A100: the CUDA runtime and shim tests, and most CUDA Python tests (PyTorch 2.14), pass. The new per-thread, strided read-back and cross-thread copy tests fail without their fix. - With the Windows layout emulated on Linux, `test_cuda_current_stream` fails before the change and passes after. CI's Windows CUDA runtime job runs it in that layout, but does not build the shim tests, so `RefusedInsideGraphCapture` and `CopiesWhileAnotherThreadCaptures` run only on Linux. Windows was not built locally. - A100 with PyTorch 2.15: the low-memory tests pass and fail without the change. In low-memory mode, a model with 512 MiB of weights peaks at about 0.66 GiB of GPU memory instead of 1.16 GiB and matches eager. With PyTorch 2.14, nothing changes. - Pending: exporting the video policy in low-memory mode. --- .ci/scripts/test_cuda_runtime_windows.ps1 | 1 + .ci/scripts/wheel/test_shared_libraries.py | 10 +- .github/workflows/cuda.yml | 3 +- backends/aoti/common_shims_slim.cpp | 3 + backends/aoti/slim/core/storage.h | 18 +++- backends/aoti/slim/cuda/guard.cpp | 71 +-------------- backends/aoti/slim/cuda/guard.h | 47 +--------- backends/cuda/CMakeLists.txt | 27 +++--- backends/cuda/cuda_backend.py | 12 +++ backends/cuda/runtime/cuda_backend.cpp | 3 +- backends/cuda/runtime/shims/memory.cpp | 11 ++- backends/cuda/runtime/shims/rand.cu | 9 +- .../shims/tests/test_aoti_torch_copy_.cpp | 86 +++++++++++++++++- .../runtime/test/test_cuda_current_stream.cpp | 62 +++++++++++++ .../runtime/test/test_cuda_output_copy.cpp | 33 ++++++- backends/cuda/runtime/utils.h | 7 +- backends/cuda/tests/test_cuda_export.py | 79 ++++++++++++++++ docs/source/backends/cuda/cuda-overview.md | 7 ++ extension/cuda/CMakeLists.txt | 27 +++--- extension/cuda/current_stream.cpp | 91 +++++++++++++++++++ extension/cuda/current_stream.h | 77 ++++++++++++++++ extension/cuda/export.h | 9 +- extension/cuda/targets.bzl | 11 ++- 23 files changed, 531 insertions(+), 173 deletions(-) create mode 100644 backends/cuda/runtime/test/test_cuda_current_stream.cpp create mode 100644 extension/cuda/current_stream.cpp create mode 100644 extension/cuda/current_stream.h diff --git a/.ci/scripts/test_cuda_runtime_windows.ps1 b/.ci/scripts/test_cuda_runtime_windows.ps1 index 1414b9d08c9..5a11df96895 100644 --- a/.ci/scripts/test_cuda_runtime_windows.ps1 +++ b/.ci/scripts/test_cuda_runtime_windows.ps1 @@ -35,6 +35,7 @@ $tests = @( "test_cuda_weight_cache", "test_cuda_output_copy", "test_cuda_sort_rand", + "test_cuda_current_stream", "test_cuda_guard", "test_cuda_stream_guard" ) diff --git a/.ci/scripts/wheel/test_shared_libraries.py b/.ci/scripts/wheel/test_shared_libraries.py index 06d39ba3dce..bf39abf294a 100644 --- a/.ci/scripts/wheel/test_shared_libraries.py +++ b/.ci/scripts/wheel/test_shared_libraries.py @@ -81,14 +81,14 @@ _CUDA_BACKEND_SYMBOLS = ("executorch::backends::cuda::load_library",) _CUDA_EXTENSION_SYMBOLS = ( "executorch::extension::cuda::getCallerStream", + "executorch::extension::cuda::internal::getCurrentCUDAStream", "executorch::extension::cuda::CudaAllocator::instance", ) -# The AOTI shim layer and the stream-guard state that lives with it. This is the state that was -# genuinely duplicated: extracting the shims with a PUBLIC whole-archive replayed the extraction at -# every consumer's link, so the guard's thread_local and these shims landed in three shipped binaries -# at once, and a stream selected through one copy was invisible to the other two. The row above cannot -# catch that, because its symbol only ever existed in one unconditionally shared library. +# The AOTI shim layer. This is what was once genuinely duplicated: extracting the shims with a PUBLIC +# whole-archive replayed the extraction at every consumer's link, so these shims, and the current-stream +# thread_local that then lived with them, landed in three shipped binaries at once, and a stream selected +# through one copy was invisible to the other two. That state now lives in the extension library above. _AOTI_SHIM_SYMBOLS = ( "aoti_torch_empty_strided", "aoti_torch_delete_tensor_object", diff --git a/.github/workflows/cuda.yml b/.github/workflows/cuda.yml index 296878243df..318664a4dcf 100644 --- a/.github/workflows/cuda.yml +++ b/.github/workflows/cuda.yml @@ -782,12 +782,13 @@ jobs: -v -o "addopts=" cmake --preset llm-release-cuda -DEXECUTORCH_BUILD_TESTS=ON - cmake --build cmake-out --target test_cuda_allocator test_cuda_allocator_exit test_cuda_mutable_state test_cuda_weight_cache test_cuda_output_copy test_cuda_sort_rand test_cuda_kv_cache test_cuda_guard test_cuda_stream_guard test_step_plan test_cuda_executor -j$(nproc) + cmake --build cmake-out --target test_cuda_allocator test_cuda_allocator_exit test_cuda_mutable_state test_cuda_weight_cache test_cuda_output_copy test_cuda_sort_rand test_cuda_current_stream test_cuda_kv_cache test_cuda_guard test_cuda_stream_guard test_step_plan test_cuda_executor -j$(nproc) ctest --test-dir cmake-out -R test_cuda_allocator --output-on-failure -V ctest --test-dir cmake-out -R test_cuda_mutable_state --output-on-failure -V ctest --test-dir cmake-out -R test_cuda_weight_cache --output-on-failure -V ctest --test-dir cmake-out -R test_cuda_output_copy --output-on-failure -V ctest --test-dir cmake-out -R test_cuda_sort_rand --output-on-failure -V + ctest --test-dir cmake-out -R test_cuda_current_stream --output-on-failure -V ctest --test-dir cmake-out -R test_cuda_kv_cache --output-on-failure -V ctest --test-dir cmake-out -R test_step_plan --output-on-failure -V # CudaExecutor end to end on a toy decoder, against its eager greedy diff --git a/backends/aoti/common_shims_slim.cpp b/backends/aoti/common_shims_slim.cpp index e44c583b0b9..7217d771d01 100644 --- a/backends/aoti/common_shims_slim.cpp +++ b/backends/aoti/common_shims_slim.cpp @@ -10,6 +10,9 @@ #include #include +// Keep this file free of static and thread_local state: a Windows build +// without EXECUTORCH_BUILD_SHARED links it twice (see backends/cuda). + namespace executorch { namespace backends { namespace aoti { diff --git a/backends/aoti/slim/core/storage.h b/backends/aoti/slim/core/storage.h index 7994540e386..f878b99c2d8 100644 --- a/backends/aoti/slim/core/storage.h +++ b/backends/aoti/slim/core/storage.h @@ -178,17 +178,25 @@ struct DeviceTraits { static_cast(dst_device.index())); } - // Plain cudaMemcpy is host-synchronous on the default stream, which a - // green context would not confine. When a caller stream is active, copy - // on it asynchronously and synchronize it to preserve blocking - // semantics; otherwise fall back to the plain synchronous copy. + // Plain cudaMemcpy runs on the legacy default stream, which a green + // context would not confine, and which fails while another thread is + // capturing a CUDA graph. So copy on the caller stream, or else on the + // current stream. const auto caller_stream = executorch::extension::cuda::getCallerStream(); if (caller_stream) { ET_CUDA_CHECK( cudaMemcpyAsync(dst, src, nbytes, direction, *caller_stream)); ET_CUDA_CHECK(cudaStreamSynchronize(*caller_stream)); } else { - ET_CUDA_CHECK(cudaMemcpy(dst, src, nbytes, direction)); + auto current = executorch::backends::cuda::getCurrentCUDAStream( + src_device.is_cuda() ? src_device.index() : dst_device.index()); + ET_CHECK_MSG(current.ok(), "Failed to get current CUDA stream"); + ET_CUDA_CHECK( + cudaMemcpyAsync(dst, src, nbytes, direction, current.get())); + // cudaMemcpy does not wait for a copy between device buffers. + if (direction != cudaMemcpyDeviceToDevice) { + ET_CUDA_CHECK(cudaStreamSynchronize(current.get())); + } } } }; diff --git a/backends/aoti/slim/cuda/guard.cpp b/backends/aoti/slim/cuda/guard.cpp index fb78c40bc49..56213486afc 100644 --- a/backends/aoti/slim/cuda/guard.cpp +++ b/backends/aoti/slim/cuda/guard.cpp @@ -9,76 +9,11 @@ #include #include #include -#include -#include -namespace executorch::backends::cuda { - -namespace { -// Thread-local stream storage (private to this file) -thread_local std::unordered_map current_streams_; -} // namespace - -Error setCurrentCUDAStream(cudaStream_t stream, DeviceIndex device_index) { - if (device_index == -1) { - // Get current device if not specified - // CUDA API returns int, explicit cast to DeviceIndex (int8_t) following - // ATen - int tmp_device = -1; - ET_CUDA_CHECK_OR_RETURN_ERROR(cudaGetDevice(&tmp_device)); - device_index = static_cast(tmp_device); - } - - current_streams_[device_index] = stream; - return Error::Ok; -} - -Result getCurrentCUDAStream(DeviceIndex device_index) { - if (device_index == -1) { - // CUDA API returns int, explicit cast to DeviceIndex (int8_t) following - // ATen - int tmp_device = -1; - ET_CUDA_CHECK_OR_RETURN_ERROR(cudaGetDevice(&tmp_device)); - device_index = static_cast(tmp_device); - } +// Keep this file free of static and thread_local state: a Windows build +// without EXECUTORCH_BUILD_SHARED links it twice (see backends/cuda). - auto it = current_streams_.find(device_index); - if (it != current_streams_.end()) { - return it->second; - } - - cudaStream_t stream; - ET_CUDA_CHECK_OR_RETURN_ERROR(cudaStreamCreate(&stream)); - setCurrentCUDAStream(stream, device_index); - return stream; -} - -std::optional peekCurrentCUDAStream(DeviceIndex device_index) { - if (device_index == -1) { - int tmp_device = -1; - if (cudaGetDevice(&tmp_device) != cudaSuccess) { - return std::nullopt; - } - device_index = static_cast(tmp_device); - } - - auto it = current_streams_.find(device_index); - if (it == current_streams_.end()) { - return std::nullopt; - } - return it->second; -} - -void clearCurrentCUDAStream(DeviceIndex device_index) { - if (device_index == -1) { - int tmp_device = -1; - if (cudaGetDevice(&tmp_device) != cudaSuccess) { - return; - } - device_index = static_cast(tmp_device); - } - current_streams_.erase(device_index); -} +namespace executorch::backends::cuda { CUDAStreamGuard::CUDAStreamGuard(CUDAStreamGuard&& other) noexcept : device_guard_(std::move(other.device_guard_)), diff --git a/backends/aoti/slim/cuda/guard.h b/backends/aoti/slim/cuda/guard.h index 5a75fc01260..7a51e11bc7d 100644 --- a/backends/aoti/slim/cuda/guard.h +++ b/backends/aoti/slim/cuda/guard.h @@ -8,10 +8,9 @@ #pragma once -#include - #include #include +#include #include #include #include @@ -20,6 +19,10 @@ namespace executorch::backends::cuda { using ::executorch::extension::cuda::CUDAGuard; +using ::executorch::extension::cuda::internal::clearCurrentCUDAStream; +using ::executorch::extension::cuda::internal::getCurrentCUDAStream; +using ::executorch::extension::cuda::internal::peekCurrentCUDAStream; +using ::executorch::extension::cuda::internal::setCurrentCUDAStream; using executorch::runtime::Error; using executorch::runtime::Result; @@ -29,46 +32,6 @@ using executorch::runtime::Result; // calls, and explicit static_cast handles inbound conversions from CUDA APIs. using executorch::backends::aoti::slim::c10::DeviceIndex; -/** - * Set the current CUDA stream for the specified device. - * - * @param stream The CUDA stream to set as current - * @param device_index The device index (-1 to use current device) - * @return Error code indicating success or failure - */ -Error setCurrentCUDAStream(cudaStream_t stream, DeviceIndex device_index = -1); - -/** - * Get the current CUDA stream for the specified device. - * If no stream has been set, creates a new stream and sets it as current. - * - * @param device_index The device index (-1 to use current device) - * @return Result containing the current stream on success, or an error code on - * failure - */ -Result getCurrentCUDAStream(DeviceIndex device_index = -1); - -/** - * The CUDA stream registered for the specified device, or std::nullopt if none - * is set. Unlike getCurrentCUDAStream, it never creates one, so it can snapshot - * the current selection without side effects. Also returns std::nullopt if the - * current device cannot be queried (device_index -1), so nullopt does not - * distinguish "no stream set" from "device query failed". - * - * @param device_index The device index (-1 to use current device) - */ -std::optional peekCurrentCUDAStream( - DeviceIndex device_index = -1); - -/** - * Clears any CUDA stream registered for the specified device, restoring the - * "no stream selected" state. Best-effort: if device_index is -1 and the - * current device cannot be queried, it silently does nothing. - * - * @param device_index The device index (-1 to use current device) - */ -void clearCurrentCUDAStream(DeviceIndex device_index = -1); - /** * RAII guard that sets the current CUDA device and stream, restoring both on * destruction. This is useful for temporarily switching to a different device diff --git a/backends/cuda/CMakeLists.txt b/backends/cuda/CMakeLists.txt index fc89ab581bf..b5cb4b3cf09 100644 --- a/backends/cuda/CMakeLists.txt +++ b/backends/cuda/CMakeLists.txt @@ -310,10 +310,9 @@ if(_cuda_is_msvc_toolchain) else() # The whole-archive pair is PRIVATE so it applies to this library's own link # and does not replay at the link of anything that links this one. In PUBLIC - # scope the archive was extracted again by each consumer, so the translation - # unit holding SlimTensor's per-device current stream thread_local landed in - # three shipped binaries, and one copy per process is what makes that state - # agree. The MSVC branch above uses PRIVATE for the same reason. + # scope the archive was extracted again by each consumer, so its objects + # landed in three shipped binaries. The MSVC branch above uses PRIVATE for the + # same reason. target_link_libraries( aoti_cuda_shims PRIVATE cuda_platform -Wl,--whole-archive aoti_common_shims_slim @@ -337,10 +336,9 @@ endif() # source install keeps its own paths. if(EXECUTORCH_BUILD_SHARED) if(_cuda_is_windows_msvc) - # In the shared layout the delegate is its own DLL and imports the stream - # guard functions (get/set/peek/clearCurrentCUDAStream) from this one. They - # carry no AOTI_SHIM_EXPORT, so the annotations alone would not export them; - # export everything, as every other shipped DLL does. + # In the shared layout the delegate is its own DLL and links this one. + # Export everything, as every other shipped DLL does, not only the shims + # marked AOTI_SHIM_EXPORT. set_target_properties( aoti_cuda_shims PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS ON ) @@ -426,9 +424,11 @@ if(EXECUTORCH_BUILD_EXTENSION_LLM) endif() if(_cuda_is_msvc_toolchain) - # cuda_backend.cpp uses SlimTensor CUDA utilities (e.g. getCurrentCUDAStream) - # from aoti_common_shims_slim via headers; propagate the static lib so final - # MSVC links (e.g. parakeet_runner) can resolve those C++ symbols. + # The shims DLL compiles these sources itself (aoti_common_shims_slim_obj), + # and cuda_backend.cpp still needs the SlimTensor headers and definitions that + # come with the static library. So the program carries a second copy of that + # code, which must hold no state; the current stream lives in extension_cuda + # for that reason. target_link_libraries( aoti_cuda_backend PRIVATE aoti_cuda_shims @@ -508,6 +508,11 @@ if(BUILD_TESTING) ) target_compile_definitions(test_cuda_sort_rand PRIVATE CUDA_AVAILABLE=1) endif() + et_cxx_test( + test_cuda_current_stream SOURCES runtime/test/test_cuda_current_stream.cpp + EXTRA_LIBS aoti_cuda_backend + ) + target_compile_definitions(test_cuda_current_stream PRIVATE CUDA_AVAILABLE=1) if(EXECUTORCH_BUILD_EXTENSION_LLM) et_cxx_test( diff --git a/backends/cuda/cuda_backend.py b/backends/cuda/cuda_backend.py index b35a6373dd4..fcf8cc98944 100644 --- a/backends/cuda/cuda_backend.py +++ b/backends/cuda/cuda_backend.py @@ -1359,6 +1359,18 @@ def get_aoti_compile_options( options["max_autotune"] = max_autotune if autotune_at_compile_time is not None: options["triton.autotune_at_compile_time"] = autotune_at_compile_time + # The final autotuning pass picks each kernel's launch settings by + # running it on random tensors shaped like its inputs, weights included, + # so in low-memory mode the real weights wait in a file meanwhile instead + # of taking GPU memory next to their random stand-ins. The threshold of + # 0.0 moves them for every model, because low-memory mode is for models + # that are short of GPU memory, and moving a small model costs little. + # TODO: drop the hasattr once every torch the repo installs has the option. + if cls._is_low_memory_mode(compile_specs) and hasattr( + torch._inductor.config.aot_inductor, + "autotune_offload_constants_min_device_fraction", + ): + options["aot_inductor.autotune_offload_constants_min_device_fraction"] = 0.0 # Add platform-specific options if platform == "windows": diff --git a/backends/cuda/runtime/cuda_backend.cpp b/backends/cuda/runtime/cuda_backend.cpp index 29c9959a6e3..610f7c46b77 100644 --- a/backends/cuda/runtime/cuda_backend.cpp +++ b/backends/cuda/runtime/cuda_backend.cpp @@ -695,8 +695,7 @@ class ET_EXPERIMENTAL CudaBackend final NotSupported, "CUDA graph is not supported together with a caller-provided CUDA stream."); - // Snapshot the prior selection without creating one (peek, not get), so the - // restore is exact and we don't leak a stream just to snapshot. + // Snapshot the prior selection with peek, not get, so the restore is exact. std::optional prev_stream; if (caller_stream) { prev_stream = peekCurrentCUDAStream(0); diff --git a/backends/cuda/runtime/shims/memory.cpp b/backends/cuda/runtime/shims/memory.cpp index f36b283b4c0..ae0021127d1 100644 --- a/backends/cuda/runtime/shims/memory.cpp +++ b/backends/cuda/runtime/shims/memory.cpp @@ -46,10 +46,11 @@ using c10::ScalarType; namespace { -// A graph capture cannot record a synchronous copy to or from device memory, -// and such a copy aborts the process when it fails, so a shim that makes one -// refuses first. Info, not Error: the backend then retries the call without a -// graph. An empty tensor copies nothing, so it needs no check. +// A graph capture cannot record a copy that the host waits for, and such a copy +// aborts the process when it fails, so a shim that copies refuses first, even +// between device buffers, to keep one rule. Info, not Error: the backend then +// retries the call without a graph. An empty tensor copies nothing, so it needs +// no check. AOTITorchError refuse_inside_capture( const SlimTensor* tensor, const char* what) { @@ -438,7 +439,7 @@ AOTITorchError aoti_torch_as_strided( AOTITorchError aoti_torch_copy_(SlimTensor* self, SlimTensor* src, int32_t non_blocking) { - (void)non_blocking; // SlimTensor::copy_() is always synchronous for now + (void)non_blocking; // SlimTensor::copy_() decides when the host waits ET_CHECK_OR_RETURN_ERROR( self != nullptr, InvalidArgument, "aoti_torch_copy_: self is null"); diff --git a/backends/cuda/runtime/shims/rand.cu b/backends/cuda/runtime/shims/rand.cu index 85a64e1ffd3..eaea9aaaa40 100644 --- a/backends/cuda/runtime/shims/rand.cu +++ b/backends/cuda/runtime/shims/rand.cu @@ -74,10 +74,11 @@ bool ensure_rng_init(cudaStream_t stream) { h.seed = (static_cast(entropy()) << 32) | entropy(); h.counter = 0; h.base_scratch = 0; - // The current device: the stream callers pass from getCurrentCUDAStream - // may have been created on it whatever index they looked up, and the - // cudaMalloc fallback on a device without memory pools follows this - // index rather than the stream. + // The current device: a stream handle such as cudaStreamPerThread, which + // getCurrentCUDAStream returns when none is selected, names a stream of + // the current device whatever index was looked up, and the cudaMalloc + // fallback on a device without memory pools follows this index rather + // than the stream. auto rng = CudaAllocator::allocate_stream_ordered(sizeof(RngState), -1, stream); if (!rng.ok()) { diff --git a/backends/cuda/runtime/shims/tests/test_aoti_torch_copy_.cpp b/backends/cuda/runtime/shims/tests/test_aoti_torch_copy_.cpp index df67f002184..a50a18e3009 100644 --- a/backends/cuda/runtime/shims/tests/test_aoti_torch_copy_.cpp +++ b/backends/cuda/runtime/shims/tests/test_aoti_torch_copy_.cpp @@ -8,12 +8,13 @@ #include #include +#include #include #include #include #include -#include +#include #include #include #include @@ -490,12 +491,13 @@ TEST_F(AOTITorchCopySlimTest, RefusedInsideGraphCapture) { cudaStream_t stream = nullptr; ASSERT_EQ(cudaStreamCreate(&stream), cudaSuccess); - ASSERT_EQ(setCurrentCUDAStream(stream, 0), Error::Ok); + CUDAStreamGuardHandle stream_guard = nullptr; + ASSERT_EQ( + aoti_torch_create_cuda_stream_guard(stream, 0, &stream_guard), Error::Ok); ASSERT_EQ( cudaStreamBeginCapture(stream, cudaStreamCaptureModeRelaxed), cudaSuccess); - // A capture cannot record these synchronous copies, so each one refuses - // instead of failing inside the copy. + // Each copy refuses inside a capture instead of failing inside the copy. EXPECT_EQ(aoti_torch_copy_(host, device, 0), Error::NotSupported); EXPECT_EQ(aoti_torch_copy_(device, host, 0), Error::NotSupported); EXPECT_EQ(aoti_torch_copy_(other_device, device, 0), Error::NotSupported); @@ -518,7 +520,7 @@ TEST_F(AOTITorchCopySlimTest, RefusedInsideGraphCapture) { } EXPECT_EQ(aoti_torch_copy_(host, device, 0), Error::Ok); - clearCurrentCUDAStream(0); + EXPECT_EQ(aoti_torch_delete_cuda_stream_guard(stream_guard), Error::Ok); EXPECT_EQ(cudaStreamDestroy(stream), cudaSuccess); EXPECT_EQ(aoti_torch_delete_tensor_object(device), Error::Ok); EXPECT_EQ(aoti_torch_delete_tensor_object(other_device), Error::Ok); @@ -527,6 +529,80 @@ TEST_F(AOTITorchCopySlimTest, RefusedInsideGraphCapture) { EXPECT_EQ(aoti_torch_delete_tensor_object(other_empty_device), Error::Ok); } +TEST_F(AOTITorchCopySlimTest, CopiesWhileAnotherThreadCaptures) { + if (!isCudaAvailable()) { + GTEST_SKIP() << "CUDA not available"; + } + + std::vector sizes = {2, 3}; + Tensor* host_src = createTestTensor(sizes); + Tensor* device = createTestTensor( + sizes, + {}, + static_cast(slim_c10::ScalarType::Float), + static_cast(slim_c10::DeviceType::CUDA), + 0); + Tensor* host_dst = createTestTensor(sizes); + ASSERT_NE(host_src, nullptr); + ASSERT_NE(device, nullptr); + ASSERT_NE(host_dst, nullptr); + float* src_data = static_cast(host_src->data_ptr()); + for (int64_t i = 0; i < host_src->numel(); i++) { + src_data[i] = static_cast(i + 1); + } + // The other thread copies on its own stream, so let the allocation finish. + ASSERT_EQ(cudaDeviceSynchronize(), cudaSuccess); + + cudaStream_t stream = nullptr; + ASSERT_EQ(cudaStreamCreate(&stream), cudaSuccess); + ASSERT_EQ( + cudaStreamBeginCapture(stream, cudaStreamCaptureModeRelaxed), + cudaSuccess); + // This thread is capturing, so the copies run on another one, which is not. + AOTITorchError to_device = Error::Internal; + AOTITorchError cloned = Error::Internal; + AOTITorchError read = Error::Internal; + AOTITorchError to_host = Error::Internal; + Tensor* clone = nullptr; + Tensor* first_element = nullptr; + float first = 0; + std::thread([&] { + to_device = aoti_torch_copy_(device, host_src, 0); + cloned = aoti_torch_clone(device, &clone); + if (cloned == Error::Ok) { + const int64_t one = 1; + read = aoti_torch__reinterpret_tensor( + clone, 1, &one, &one, 0, &first_element); + if (read == Error::Ok) { + read = aoti_torch_item_float32(first_element, &first); + } + to_host = aoti_torch_copy_(host_dst, clone, 0); + } + }).join(); + cudaGraph_t graph = nullptr; + EXPECT_EQ(cudaStreamEndCapture(stream, &graph), cudaSuccess); + if (graph != nullptr) { + EXPECT_EQ(cudaGraphDestroy(graph), cudaSuccess); + } + + EXPECT_EQ(to_device, Error::Ok); + ASSERT_EQ(cloned, Error::Ok); + EXPECT_EQ(read, Error::Ok); + EXPECT_FLOAT_EQ(first, 1.0f); + EXPECT_EQ(to_host, Error::Ok); + float* dst_data = static_cast(host_dst->data_ptr()); + for (int64_t i = 0; i < host_dst->numel(); i++) { + EXPECT_FLOAT_EQ(dst_data[i], static_cast(i + 1)); + } + + EXPECT_EQ(cudaStreamDestroy(stream), cudaSuccess); + EXPECT_EQ(aoti_torch_delete_tensor_object(host_src), Error::Ok); + EXPECT_EQ(aoti_torch_delete_tensor_object(device), Error::Ok); + EXPECT_EQ(aoti_torch_delete_tensor_object(first_element), Error::Ok); + EXPECT_EQ(aoti_torch_delete_tensor_object(clone), Error::Ok); + EXPECT_EQ(aoti_torch_delete_tensor_object(host_dst), Error::Ok); +} + // ============================================================================ // Non-blocking Tests // ============================================================================ diff --git a/backends/cuda/runtime/test/test_cuda_current_stream.cpp b/backends/cuda/runtime/test/test_cuda_current_stream.cpp new file mode 100644 index 00000000000..a40da7cf138 --- /dev/null +++ b/backends/cuda/runtime/test/test_cuda_current_stream.cpp @@ -0,0 +1,62 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +// The delegate selects a stream and the shims read it back, so both have to +// share one current-stream state. A Windows build without +// EXECUTORCH_BUILD_SHARED is where two copies could appear: this program links +// the static backend, and the shims sit in their own DLL. + +#include + +#include +#include + +#include +#include + +using executorch::backends::cuda::aoti_torch_get_current_cuda_stream; +using executorch::backends::cuda::clearCurrentCUDAStream; +using executorch::backends::cuda::setCurrentCUDAStream; +using executorch::runtime::Error; + +class CudaCurrentStreamTest : public ::testing::Test { + protected: + void SetUp() override { + et_pal_init(); + int count = 0; + if (cudaGetDeviceCount(&count) != cudaSuccess || count == 0) { + if (std::getenv("EXECUTORCH_CUDA_TEST_REQUIRE_NO_MEMORY_POOLS") != + nullptr) { + FAIL() << "this job expects a CUDA device, but none is available"; + } + GTEST_SKIP() << "CUDA not available"; + } + } +}; + +TEST_F(CudaCurrentStreamTest, ShimsSeeTheStreamTheBackendSelects) { + cudaStream_t stream = nullptr; + ASSERT_EQ(cudaStreamCreate(&stream), cudaSuccess); + ASSERT_EQ(setCurrentCUDAStream(stream, 0), Error::Ok); + void* seen = nullptr; + EXPECT_EQ(aoti_torch_get_current_cuda_stream(0, &seen), Error::Ok); + EXPECT_EQ(seen, static_cast(stream)); + + clearCurrentCUDAStream(0); + EXPECT_EQ(cudaStreamDestroy(stream), cudaSuccess); +} + +// The delegate runs on cudaStreamPerThread when the caller selects no stream, +// so a thread that has selected none uses it too. +TEST_F(CudaCurrentStreamTest, ShimsUseThePerThreadStreamWhenNoneIsSelected) { + void* seen = nullptr; + std::thread([&seen] { + EXPECT_EQ(aoti_torch_get_current_cuda_stream(0, &seen), Error::Ok); + }).join(); + EXPECT_EQ(seen, static_cast(cudaStreamPerThread)); +} diff --git a/backends/cuda/runtime/test/test_cuda_output_copy.cpp b/backends/cuda/runtime/test/test_cuda_output_copy.cpp index e1b72bd2f17..8f28cdac3ab 100644 --- a/backends/cuda/runtime/test/test_cuda_output_copy.cpp +++ b/backends/cuda/runtime/test/test_cuda_output_copy.cpp @@ -10,6 +10,8 @@ #include +#include + namespace slim = ::executorch::backends::aoti::slim; using namespace ::executorch::runtime; using namespace ::executorch::backends::cuda; @@ -49,14 +51,14 @@ class CudaOutputCopyTest : public ::testing::Test { storage_offset); } - Error copy(const slim::SlimTensor& source) { + Error copy(const slim::SlimTensor& source, cudaStream_t stream = nullptr) { etensor::TensorImpl::SizesType sizes[] = {2, 2}; etensor::TensorImpl::DimOrderType dim_order[] = {0, 1}; etensor::TensorImpl::StridesType strides[] = {2, 1}; etensor::TensorImpl impl( etensor::ScalarType::Float, 2, sizes, destination_, dim_order, strides); etensor::Tensor output(&impl); - return copy_slimtensor_to_device_etensor_async(&source, &output, nullptr); + return copy_slimtensor_to_device_etensor_async(&source, &output, stream); } std::vector copied() { @@ -93,6 +95,33 @@ TEST_F(CudaOutputCopyTest, CopiesAStridedView) { EXPECT_EQ(copied(), (std::vector{1, 2, 5, 6})); } +// While one thread captures a CUDA graph, CUDA rejects any use of the legacy +// stream, so another thread's copy has to stay on its own stream. +TEST_F(CudaOutputCopyTest, CopiesAStridedViewWhileAnotherThreadCaptures) { + slim::SlimTensor columns = slim::from_blob( + source_, {2, 4}, slim::c10::ScalarType::Float, slim::DEFAULT_CUDA_DEVICE); + columns.as_strided_({2, 2}, {4, 1}, /*storage_offset=*/1); + + cudaStream_t capturing = nullptr; + ASSERT_EQ(cudaStreamCreate(&capturing), cudaSuccess); + ASSERT_EQ( + cudaStreamBeginCapture(capturing, cudaStreamCaptureModeRelaxed), + cudaSuccess); + Error copied_while_capturing = Error::Internal; + std::thread([&] { + copied_while_capturing = copy(columns, cudaStreamPerThread); + }).join(); + cudaGraph_t graph = nullptr; + EXPECT_EQ(cudaStreamEndCapture(capturing, &graph), cudaSuccess); + if (graph != nullptr) { + EXPECT_EQ(cudaGraphDestroy(graph), cudaSuccess); + } + EXPECT_EQ(cudaStreamDestroy(capturing), cudaSuccess); + + ASSERT_EQ(copied_while_capturing, Error::Ok); + EXPECT_EQ(copied(), (std::vector{1, 2, 5, 6})); +} + TEST_F(CudaOutputCopyTest, RejectsAViewOutsideItsStorage) { // from_blob sizes the storage to fit its view, so moving the view past the // end takes as_strided_. The moved view is still inside the allocation. diff --git a/backends/cuda/runtime/utils.h b/backends/cuda/runtime/utils.h index 6dd860e11e1..df0cf75f109 100644 --- a/backends/cuda/runtime/utils.h +++ b/backends/cuda/runtime/utils.h @@ -231,10 +231,13 @@ inline executorch::runtime::Error _copy_slimtensor_to_etensor_impl( std::memcpy(tmp.data(), src_data, span_nbytes); } else { if (stream) { + ET_CUDA_CHECK_OR_RETURN_ERROR(cudaMemcpyAsync( + tmp.data(), src_data, span_nbytes, cudaMemcpyDeviceToHost, stream)); ET_CUDA_CHECK_OR_RETURN_ERROR(cudaStreamSynchronize(stream)); + } else { + ET_CUDA_CHECK_OR_RETURN_ERROR(cudaMemcpy( + tmp.data(), src_data, span_nbytes, cudaMemcpyDeviceToHost)); } - ET_CUDA_CHECK_OR_RETURN_ERROR(cudaMemcpy( - tmp.data(), src_data, span_nbytes, cudaMemcpyDeviceToHost)); } const size_t ndim = slim_tensor->dim(); diff --git a/backends/cuda/tests/test_cuda_export.py b/backends/cuda/tests/test_cuda_export.py index 1e9489479bc..468890bc5fb 100644 --- a/backends/cuda/tests/test_cuda_export.py +++ b/backends/cuda/tests/test_cuda_export.py @@ -24,6 +24,10 @@ from torch.export import export from torch.nn.attention.varlen import varlen_attn +_AUTOTUNE_OFFLOAD = "autotune_offload_constants_min_device_fraction" +_TORCH_CAN_OFFLOAD = hasattr(torch._inductor.config.aot_inductor, _AUTOTUNE_OFFLOAD) +_LOW_MEMORY_ON = CompileSpec(key="low_memory_mode", value=b"ON") + class TestCudaBackendCompileOptions(unittest.TestCase): def test_low_memory_triton_reduction_loads_stay_loop_scoped(self): @@ -83,6 +87,21 @@ def test_invalid_emulate_precision_casts_compile_spec(self): [CompileSpec(key="emulate_precision_casts", value=b"MAYBE")] ) + def test_low_memory_mode_offloads_weights_during_autotune(self): + key = f"aot_inductor.{_AUTOTUNE_OFFLOAD}" + default = CudaBackend.get_aoti_compile_options([]) + self.assertNotIn(key, default) + self.assertEqual( + CudaBackend.get_aoti_compile_options( + [CompileSpec(key="low_memory_mode", value=b"OFF")] + ), + default, + ) + low_memory = CudaBackend.get_aoti_compile_options([_LOW_MEMORY_ON]) + # The offload is the only compile option low-memory mode changes. + expected = dict(default, **{key: 0.0}) if _TORCH_CAN_OFFLOAD else default + self.assertEqual(low_memory, expected) + def test_max_autotune_compile_spec(self): options = CudaBackend.get_aoti_compile_options( [CompileSpec(key="max_autotune", value=b"OFF")] @@ -614,6 +633,66 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: edge_program_manager = self._export_to_cuda_with_lower(module, inputs) self.assertIsNotNone(edge_program_manager, "Linear operation export failed") + @unittest.skipUnless( + _TORCH_CAN_OFFLOAD, + "This PyTorch cannot move the weights off the GPU during autotuning", + ) + @unittest.skipIf( + torch.version.hip is not None, + "loads through the Python runtime, which the ROCm build does not include", + ) + def test_low_memory_mode_holds_weights_once(self): + """Autotuning runs kernels on random stand-ins for the weights. In + low-memory mode, the real weights leave the GPU meanwhile instead of + doubling up.""" + + class ScaleShift(torch.nn.Module): + def __init__(self): + super().__init__() + self.weights = torch.nn.ParameterList( + torch.nn.Parameter(torch.randn(1 << 24)) for _ in range(8) + ) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + for weight in self.weights: + x = x * weight + 1 + return x + + from executorch.runtime import Runtime + + module = ScaleShift().eval() + x = torch.randn(1 << 24) + weight_bytes = sum(weight.nbytes for weight in module.weights) + before = torch.cuda.memory_allocated() + torch.cuda.reset_peak_memory_stats() + program = self._export_to_cuda_with_lower( + module, + (x,), + [ + CudaBackend.generate_method_name_compile_spec("forward"), + _LOW_MEMORY_ON, + ], + ).to_executorch() + self.assertLess(torch.cuda.max_memory_allocated() - before, 2 * weight_bytes) + # The weights come back from the file, so the program must still match. + with tempfile.TemporaryDirectory() as directory: + path = os.path.join(directory, "model.pte") + with open(path, "wb") as f: + program.write_to_file(f) + program.write_tensor_data_to_file(directory) + (data,) = [ + os.path.join(directory, name) + for name in os.listdir(directory) + if name.endswith(".ptd") + ] + method = ( + Runtime.get().load_program(path, data_path=data).load_method("forward") + ) + (output,) = method.execute([x]) + with torch.no_grad(): + expected = module(x) + torch.testing.assert_close(output.cpu(), expected, atol=1e-4, rtol=1e-4) + def test_resnet_block(self): """Test CUDA export for a ResNet-style block.""" diff --git a/docs/source/backends/cuda/cuda-overview.md b/docs/source/backends/cuda/cuda-overview.md index 3f18d5cde6c..fd2a2471a15 100644 --- a/docs/source/backends/cuda/cuda-overview.md +++ b/docs/source/backends/cuda/cuda-overview.md @@ -102,6 +102,13 @@ from executorch.runtime import Runtime program = Runtime.get().load_program("encoder.pte", data_path="encoder.ptd") ``` +If an export runs out of GPU memory while AOTInductor compiles its kernels, `CompileSpec("low_memory_mode", b"ON")` can help. It changes several compile steps to use less GPU memory, and not every model compiles with all of them, so turn it on only when you need it. One of those steps moves the weights to a file in the Inductor cache directory during the final autotuning pass, instead of keeping them on the GPU. Good to know: + +- The move needs PyTorch 2.15, or a nightly from 2026-09-24 or later. Older PyTorch keeps the weights on the GPU. +- The cache directory needs free space for the weights. If it fills up, the process stops with a bus error. It defaults to a folder under the system temp directory, which on some systems is in RAM. Set `TORCHINDUCTOR_CACHE_DIR` to a disk with room. +- The earlier template search for matrix multiplies and convolutions still keeps the weights on the GPU. +- PyTorch skips the move when the last kernel it generates runs on the CPU. + For a complete working example, see the [CUDA export script](https://github.com/pytorch/executorch/blob/main/examples/cuda/scripts/export.py). diff --git a/extension/cuda/CMakeLists.txt b/extension/cuda/CMakeLists.txt index 668e8738e84..d9409b07a3f 100644 --- a/extension/cuda/CMakeLists.txt +++ b/extension/cuda/CMakeLists.txt @@ -27,13 +27,15 @@ else() find_package(CUDAToolkit REQUIRED) endif() -# SHARED on purpose: the caller-stream thread-local must have a single -# definition across every shared object in the process (see export.h). A static -# copy linked into multiple shared libraries would create multiple thread-locals -# and silently break the caller-stream handshake. The CUDA allocator lives here -# too, so one singleton serves every delegate. +# SHARED on purpose: the caller-stream and current-stream thread-locals must +# each have a single definition across every shared object in the process (see +# export.h). A static copy linked into multiple shared libraries would create +# multiple thread-locals and silently break the caller-stream handshake, or let +# the CUDA delegate and its shims each see a different current stream. The CUDA +# allocator lives here too, so one singleton serves every delegate. add_library( - extension_cuda SHARED caller_stream.cpp cuda_allocator.cpp device_guard.cpp + extension_cuda SHARED caller_stream.cpp cuda_allocator.cpp current_stream.cpp + device_guard.cpp ) if(EXECUTORCH_BUILD_SHARED) # Named after what it provides rather than after the target, matching the @@ -46,17 +48,18 @@ if(EXECUTORCH_BUILD_SHARED) # Ships beside libexecutorch.so in the wheel's lib/ directory. executorch_target_shipped_runtime_path(extension_cuda) endif() -# The allocator and device guard call the runtime. The caller-stream helper -# treats its handle as opaque. The toolkit is present in accelerator builds. +# The allocator, device guard and current-stream helper call the runtime. The +# caller-stream helper treats its handle as opaque. The toolkit is present in +# accelerator builds. if(EXECUTORCH_BUILD_ROCM) target_link_libraries(extension_cuda PUBLIC hip::host) else() target_link_libraries(extension_cuda PUBLIC CUDA::cudart) endif() -# The allocator and device guard log through the ExecuTorch runtime. Where one -# shared runtime exists, the helper retains it so there is a single registry. -# Where it does not, the helper does nothing, and this library carries the parts -# of the static core it needs instead. +# The allocator, device guard and current-stream helper log through the +# ExecuTorch runtime. Where one shared runtime exists, the helper retains it so +# there is a single registry. Where it does not, the helper does nothing, and +# this library carries the parts of the static core it needs instead. if(EXECUTORCH_BUILD_SHARED) executorch_target_link_shared_runtime(extension_cuda) else() diff --git a/extension/cuda/current_stream.cpp b/extension/cuda/current_stream.cpp new file mode 100644 index 00000000000..0be9cece8ef --- /dev/null +++ b/extension/cuda/current_stream.cpp @@ -0,0 +1,91 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include + +#include + +#if !defined(EXECUTORCH_USE_HIP) +#include +#endif + +namespace executorch::extension::cuda::internal { + +using ::executorch::runtime::Error; +using ::executorch::runtime::Result; + +namespace { + +thread_local std::unordered_map current_streams_; + +} // namespace + +Error setCurrentCUDAStream(cudaStream_t stream, int8_t device_index) { + if (device_index == -1) { + // CUDA reports devices as int; the index is int8_t to match ATen. + int tmp_device = -1; + cudaError_t err = cudaGetDevice(&tmp_device); + ET_CHECK_OR_RETURN_ERROR( + err == cudaSuccess, + Internal, + "setCurrentCUDAStream: cudaGetDevice failed: %s", + cudaGetErrorString(err)); + device_index = static_cast(tmp_device); + } + + current_streams_[device_index] = stream; + return Error::Ok; +} + +Result getCurrentCUDAStream(int8_t device_index) { + if (device_index == -1) { + int tmp_device = -1; + cudaError_t err = cudaGetDevice(&tmp_device); + ET_CHECK_OR_RETURN_ERROR( + err == cudaSuccess, + Internal, + "getCurrentCUDAStream: cudaGetDevice failed: %s", + cudaGetErrorString(err)); + device_index = static_cast(tmp_device); + } + + auto it = current_streams_.find(device_index); + if (it != current_streams_.end()) { + return it->second; + } + return cudaStreamPerThread; +} + +std::optional peekCurrentCUDAStream(int8_t device_index) { + if (device_index == -1) { + int tmp_device = -1; + if (cudaGetDevice(&tmp_device) != cudaSuccess) { + return std::nullopt; + } + device_index = static_cast(tmp_device); + } + + auto it = current_streams_.find(device_index); + if (it == current_streams_.end()) { + return std::nullopt; + } + return it->second; +} + +void clearCurrentCUDAStream(int8_t device_index) { + if (device_index == -1) { + int tmp_device = -1; + if (cudaGetDevice(&tmp_device) != cudaSuccess) { + return; + } + device_index = static_cast(tmp_device); + } + current_streams_.erase(device_index); +} + +} // namespace executorch::extension::cuda::internal diff --git a/extension/cuda/current_stream.h b/extension/cuda/current_stream.h new file mode 100644 index 00000000000..5210c0bd318 --- /dev/null +++ b/extension/cuda/current_stream.h @@ -0,0 +1,77 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#include + +#include +#include +#include + +#if defined(EXECUTORCH_USE_HIP) +#include +#else +// Declared rather than included, as in caller_stream.h. +typedef struct CUstream_st* cudaStream_t; +#endif + +// The current stream of each device, per thread, which the CUDA delegate and +// its shims run on. It lives in this shared library, beside the caller stream, +// so that a process has one copy of it whichever libraries the delegate and its +// shims are linked into. It is the CUDA delegate's own selection, so it is +// internal: another backend should follow getCallerStream instead. +namespace executorch::extension::cuda::internal { + +/** + * Set the current CUDA stream for the specified device. + * + * @param stream The CUDA stream to set as current + * @param device_index The device index (-1 to use current device) + * @return Error code indicating success or failure + */ +EXECUTORCH_EXTENSION_CUDA_API ::executorch::runtime::Error setCurrentCUDAStream( + cudaStream_t stream, + int8_t device_index = -1); + +/** + * Get the current CUDA stream for the specified device. + * If no stream has been set, returns cudaStreamPerThread, the stream the + * delegate runs on when the caller selects none. That handle names a stream of + * the device that is current when it is used, not of device_index. + * + * @param device_index The device index (-1 to use current device) + * @return Result containing the current stream on success, or an error code on + * failure + */ +EXECUTORCH_EXTENSION_CUDA_API ::executorch::runtime::Result +getCurrentCUDAStream(int8_t device_index = -1); + +/** + * The CUDA stream registered for the specified device, or std::nullopt if none + * is set. Unlike getCurrentCUDAStream, it does not fall back to + * cudaStreamPerThread, so it can snapshot the selection exactly. Also returns + * std::nullopt if the current device cannot be queried (device_index -1), so + * nullopt does not distinguish "no stream set" from "device query failed". + * + * @param device_index The device index (-1 to use current device) + */ +EXECUTORCH_EXTENSION_CUDA_API std::optional peekCurrentCUDAStream( + int8_t device_index = -1); + +/** + * Clears any CUDA stream registered for the specified device, restoring the + * "no stream selected" state. Best-effort: if device_index is -1 and the + * current device cannot be queried, it silently does nothing. + * + * @param device_index The device index (-1 to use current device) + */ +EXECUTORCH_EXTENSION_CUDA_API void clearCurrentCUDAStream( + int8_t device_index = -1); + +} // namespace executorch::extension::cuda::internal diff --git a/extension/cuda/export.h b/extension/cuda/export.h index 4d0655b665d..7ff5a7fdf2a 100644 --- a/extension/cuda/export.h +++ b/extension/cuda/export.h @@ -8,10 +8,11 @@ #pragma once -// extension_cuda is a shared library so the caller-stream thread-local has a -// single definition across every shared object in the process; a static copy -// linked into two .so's would create two thread-locals and silently break the -// handshake. These macros export the public symbols from that one library. +// extension_cuda is a shared library so its thread-locals (the caller stream +// and the CUDA delegate's current stream) each have a single definition across +// every shared object in the process; a static copy linked into two .so's would +// create two thread-locals and silently break the handshake. These macros +// export the public symbols from that one library. #if defined(_WIN32) #if defined(EXECUTORCH_EXTENSION_CUDA_BUILDING) #define EXECUTORCH_EXTENSION_CUDA_API __declspec(dllexport) diff --git a/extension/cuda/targets.bzl b/extension/cuda/targets.bzl index 570237727ff..c4a2bcb54a3 100644 --- a/extension/cuda/targets.bzl +++ b/extension/cuda/targets.bzl @@ -21,19 +21,20 @@ def define_common_targets(): ) # Backend-neutral: both the CUDA and TensorRT delegates can depend on it to - # share a caller's stream. The caller-stream thread-local must be one - # instance per process, so the main target stays shareable: OSS cxx_library - # defaults force_static=True, which would duplicate the thread-local into - # every dependent shared object (see export.h). The :caller_stream_static - # variant stays available for fully-static consumers. + # share a caller's stream. The caller-stream and current-stream + # thread-locals must each be one instance per process, so the main target + # stays shareable: OSS cxx_library defaults force_static=True, which would + # duplicate them into every dependent shared object (see export.h). runtime.cxx_library( name = "caller_stream", srcs = [ "caller_stream.cpp", + "current_stream.cpp", "device_guard.cpp", ], exported_headers = [ "caller_stream.h", + "current_stream.h", "device_guard.h", ], exported_deps = [ From 1db3a7a9eb82af0d44c360f19f0353b7e26357e8 Mon Sep 17 00:00:00 2001 From: RJ Ascani Date: Wed, 4 Nov 2026 15:10:08 -0800 Subject: [PATCH 070/128] Cache the Arm FVP and toolchain downloads in the CI images (#24585) Every CI job that runs examples/arm/setup.sh still downloads the default FVPs and the Arm GNU toolchain from developer.arm.com, about 394 MB, and the NXP MCUXpresso test fetches the same toolchain with wget. Arm has asked us to stop hitting their CDN this often, and the pinned versions change a few times a year, so the CI images can carry them. download_with_retry now looks in ARM_SETUP_DOWNLOAD_CACHE first. A file there named after the URL is copied instead of downloaded, but only when it matches the pinned MD5, so a stale cache costs a download, never a wrong archive. Each miss is logged as a GitHub warning, so a cache that stops matching shows up on the run. The NXP script copies its toolchain from the same place. The arm-sdk and zephyr-sdk images fill /opt/arm-download-cache at build time with the Corstone-300 and Corstone-320 FVPs and the arm-none-eabi toolchain, sourcing the same fvp_utils.sh and toolchain_utils.sh as setup.sh, which build.sh copies into the build context as it already does for requirements-lintrunner.txt. The fill is best effort, so an outage of Arm's CDN cannot break the image build for jobs that never download from it. The image tag only hashes .ci/docker, so docker-builds.yml also triggers on those two files and a pin bump on main rebuilds the images under the same tag. Until a job gets the rebuilt image it downloads and warns: during the rebuild, on runner nodes that still hold the old image, on PRs based before the bump, and after a ciflow/docker build of a pin-bump PR, which overwrites main's images until the next main or weekly rebuild. Images are rebuilt about once a day, mostly for the PyTorch pin PR, so image builds still make roughly ten downloads a day, against about 10,000 from jobs before. Authored with Claude Code. cc @digantdesai @freddan80 @per @zingo @oscarandersson8218 @mansnils @Sebastian-Larsson @robell --- .ci/docker/build.sh | 4 +++ .ci/docker/common/install_arm_downloads.sh | 34 +++++++++++++++++++ .ci/docker/ubuntu/Dockerfile | 10 ++++++ .github/workflows/docker-builds.yml | 2 ++ backends/arm/scripts/utils.sh | 13 +++++++ .../test_build_from_scratch.sh | 8 ++++- 6 files changed, 70 insertions(+), 1 deletion(-) create mode 100755 .ci/docker/common/install_arm_downloads.sh diff --git a/.ci/docker/build.sh b/.ci/docker/build.sh index ef963961787..eb617fca5c1 100755 --- a/.ci/docker/build.sh +++ b/.ci/docker/build.sh @@ -116,6 +116,10 @@ fi # Copy requirements-lintrunner.txt from root to here cp ../../requirements-lintrunner.txt ./ +# Copy the Arm setup scripts that pin the FVP and toolchain downloads +mkdir -p arm-setup +cp ../../backends/arm/scripts/{utils,fvp_utils,toolchain_utils}.sh arm-setup/ + # OSDC runners have no docker daemon, so the build runs on the in-cluster # BuildKit pool via a remote buildx builder. That builder has nowhere to load # an image into, so the result has to go straight to the registry. diff --git a/.ci/docker/common/install_arm_downloads.sh b/.ci/docker/common/install_arm_downloads.sh new file mode 100755 index 00000000000..c7b3b79a981 --- /dev/null +++ b/.ci/docker/common/install_arm_downloads.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# Cache the developer.arm.com downloads of examples/arm/setup.sh, so CI jobs +# copy them from ARM_SETUP_DOWNLOAD_CACHE instead of fetching them each run. +# Jobs download whatever is missing, so a failed download only costs the cache. + +set -exu + +ARCH="$(uname -m)" +OS="$(uname -s)" +ARM_SETUP_CURL_PROGRESS_ARGS=(--no-progress-meter) +# Fill the cache rather than read from it +cache_dir="${ARM_SETUP_DOWNLOAD_CACHE}" +unset ARM_SETUP_DOWNLOAD_CACHE +source ./arm-setup/fvp_utils.sh +source ./arm-setup/toolchain_utils.sh + +cache_download() { + local url=$1 + local md5=$2 + local output="${cache_dir}/$(basename "${url}")" + download_with_retry "cache" "${url}" "${output}" "${md5}" || rm -f "${output}" +} + +mkdir -p "${cache_dir}" +cache_download "${corstone300_url}" "${corstone300_md5_checksum}" +cache_download "${corstone320_url}" "${corstone320_md5_checksum}" +gcc_select_toolchain +cache_download "${toolchain_url}" "${toolchain_md5_checksum}" diff --git a/.ci/docker/ubuntu/Dockerfile b/.ci/docker/ubuntu/Dockerfile index 27faf7d688d..8118e9cc0b1 100644 --- a/.ci/docker/ubuntu/Dockerfile +++ b/.ci/docker/ubuntu/Dockerfile @@ -103,6 +103,16 @@ COPY ./common/utils.sh utils.sh RUN if [ -n "${ZEPHYR_SDK}" ]; then bash ./install_zephyr.sh; fi RUN rm install_zephyr.sh zephyr_sdk_release_proxy.py utils.sh +# Cache the developer.arm.com downloads of examples/arm/setup.sh, which the +# Arm and Zephyr jobs run. +ENV ARM_SETUP_DOWNLOAD_CACHE=/opt/arm-download-cache +COPY ./common/install_arm_downloads.sh install_arm_downloads.sh +COPY ./arm-setup arm-setup +RUN if [ -n "${ARM_SDK}${ZEPHYR_SDK}" ]; then \ + bash ./install_arm_downloads.sh || echo "Arm download cache not populated"; \ + fi +RUN rm -r install_arm_downloads.sh arm-setup + ARG QNN_SDK ARG MEDIATEK_SDK diff --git a/.github/workflows/docker-builds.yml b/.github/workflows/docker-builds.yml index 5df95a0c06a..c80e6f03ef5 100644 --- a/.github/workflows/docker-builds.yml +++ b/.github/workflows/docker-builds.yml @@ -16,6 +16,8 @@ on: - .ci/docker/** - .github/workflows/docker-builds.yml - requirements-lintrunner.txt + - backends/arm/scripts/fvp_utils.sh + - backends/arm/scripts/toolchain_utils.sh schedule: - cron: 1 3 * * 3 diff --git a/backends/arm/scripts/utils.sh b/backends/arm/scripts/utils.sh index 160e95442fc..2f87f471e74 100644 --- a/backends/arm/scripts/utils.sh +++ b/backends/arm/scripts/utils.sh @@ -76,6 +76,9 @@ function download_with_retry() { # both are caught here. --fail rejects HTTP errors, # --retry-all-errors handles transport errors, and verify_md5 catches # truncation / wrong-content via the published archive checksum. + # If the absolute path ARM_SETUP_DOWNLOAD_CACHE holds a file named after + # the URL with the expected MD5, it is copied instead; the CI images cache + # them there. # Arg 1: log context (passed to log_step) # Arg 2: URL to download @@ -89,6 +92,16 @@ function download_with_retry() { local output="${3}" local expected_md5="${4}" + if [[ -n "${ARM_SETUP_DOWNLOAD_CACHE:-}" ]]; then + local cached="${ARM_SETUP_DOWNLOAD_CACHE}/$(basename "${url}")" + if [[ -f "${cached}" ]] && verify_md5 "${expected_md5}" "${cached}" \ + && cp "${cached}" "${output}"; then + log_step "${context}" "Using cached ${cached}" + return 0 + fi + echo "::warning::[Arm Setup/${context}] No usable ${cached}; downloading" + fi + local max_attempts=5 for attempt in $(seq 1 ${max_attempts}); do rm -f "${output}" diff --git a/examples/nxp/mcuxpresso/imxrt700/executorch_cifarnet/test_build_from_scratch.sh b/examples/nxp/mcuxpresso/imxrt700/executorch_cifarnet/test_build_from_scratch.sh index f734d1b79a1..dbc311aee1f 100755 --- a/examples/nxp/mcuxpresso/imxrt700/executorch_cifarnet/test_build_from_scratch.sh +++ b/examples/nxp/mcuxpresso/imxrt700/executorch_cifarnet/test_build_from_scratch.sh @@ -12,7 +12,13 @@ echo Downloading ARM GCC toolchain if [ ! -d arm-toolchain ]; then mkdir -p arm-toolchain pushd arm-toolchain - wget $ARM_TOOLCHAIN_URL + # The Arm CI images cache this archive (see backends/arm/scripts/utils.sh) + cached_toolchain="${ARM_SETUP_DOWNLOAD_CACHE:-}/$(basename $ARM_TOOLCHAIN_URL)" + if [ -n "${ARM_SETUP_DOWNLOAD_CACHE:-}" ] && [ -f "$cached_toolchain" ]; then + cp "$cached_toolchain" . + else + wget $ARM_TOOLCHAIN_URL + fi tar -xvf *.tar.xz rm *.tar.xz popd From 352dc97519c7eaf8b73b36c74771d088be275a58 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Wed, 4 Nov 2026 17:25:30 -0800 Subject: [PATCH 071/128] Core ML partitioner: read the stored deployment target, and skip iOS 18 test runs before macOS 15 (#24632) #24317 lets the Core ML partitioner run some ops on the CPU. Its last review pointed out a missing macOS 15 check in some tests, and a value that was read twice. This PR fixes them. ### Some tests ran iOS 18 programs on macOS 14 Core ML runs an iOS 18 program only on macOS 15 or later. One partitioner test already checks `IS_VALID_TEST_RUNTIME` before it runs such a program. These tests did not: - `test_int8_state` - `test_uint8_input_with_dynamic_shape` - the iOS 18 case of `test_symbolic_value_read_outside_the_delegate` So on macOS 14 they would fail to load the program instead of skipping the run. The first 2 always build an iOS 18 program, so they now check `IS_VALID_TEST_RUNTIME` before running it. The symbolic test checks it only for its iOS 18 case. The partitioning checks still run on every host. ### The deployment target was read twice `CoreMLPartitioner` reads the minimum deployment target from its compile specs once, in `__init__`, and keeps it. `_targets_ios18` read it from the compile specs again. It now uses the stored value, like the rest of the class. Behavior does not change. ### Testing - `test_coreml_partitioner.py` passes on Linux at each commit. Linux skips the runtime checks, so the changed checks only run on macOS. - I had no macOS 14 machine, so the skip on macOS 14 is checked by reading the code, not by running it. --- backends/apple/coreml/partition/coreml_partitioner.py | 4 +--- backends/apple/coreml/test/test_coreml_partitioner.py | 11 ++++++++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/backends/apple/coreml/partition/coreml_partitioner.py b/backends/apple/coreml/partition/coreml_partitioner.py index 962f149698a..1ef5b044655 100644 --- a/backends/apple/coreml/partition/coreml_partitioner.py +++ b/backends/apple/coreml/partition/coreml_partitioner.py @@ -1172,9 +1172,7 @@ def log_once(self, msg: str) -> None: def _targets_ios18(self) -> bool: """Whether the deployment target is iOS 18 or later.""" - target = CoreMLBackend.min_deployment_target_from_compile_specs( - self.delegation_spec.compile_specs - ) + target = self._minimum_deployment_target return target is not None and target >= ct.target.iOS18 def _placeholders_kept_inside( diff --git a/backends/apple/coreml/test/test_coreml_partitioner.py b/backends/apple/coreml/test/test_coreml_partitioner.py index 03350d27034..85f06dfd3df 100644 --- a/backends/apple/coreml/test/test_coreml_partitioner.py +++ b/backends/apple/coreml/test/test_coreml_partitioner.py @@ -1033,7 +1033,8 @@ def forward(self, x): passes=[InitializedMutableBufferPass(["state"])] ) buffer = edge.to_executorch(config).buffer - if _TEST_RUNTIME: + # An iOS 18 program needs macOS 15 to run. + if IS_VALID_TEST_RUNTIME: method = Runtime.get().load_program(buffer).load_method("forward") eager = model_class().eval() for _ in range(2): @@ -1296,7 +1297,8 @@ def forward(self, image): self.assertIn(name, op_names) self.assertIn("executorch_call_delegate", op_names) buffer = edge.to_executorch().buffer - if _TEST_RUNTIME: + # An iOS 18 program needs macOS 15 to run. + if IS_VALID_TEST_RUNTIME: y = torch.randint(0, 256, (1, 3, 12, 8), dtype=torch.uint8) program = Runtime.get().load_program(buffer) out = program.load_method("forward").execute((y,))[0] @@ -1345,7 +1347,10 @@ def forward(self, x): for name in ("aten.relu.default", "aten.add.Tensor", "aten.mul.Tensor"): self.assertNotIn(name, op_names) buffer = edge.to_executorch().buffer - if _TEST_RUNTIME: + # An iOS 18 program needs macOS 15 to run. + if _TEST_RUNTIME and ( + target < ct.target.iOS18 or IS_VALID_TEST_RUNTIME + ): program = Runtime.get().load_program(buffer) out = program.load_method("forward").execute((x,))[0] self.assertTrue(torch.allclose(out, model(x), atol=1e-2)) From 4afcdf68a4d3cbe0b0f4a460e27f3891868edec4 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Wed, 4 Nov 2026 17:28:49 -0800 Subject: [PATCH 072/128] Qualcomm AI Engine Direct - Decompose kept-whole ops that HTP leaves on CPU (#24429) ## What is wrong today On HTP, a partition that reads nothing at runtime stays on CPU. A constant-only op that QNN keeps whole can land there undecomposed, and lowering fails: ``` RuntimeError: Node aten_linear_default ... was not decomposed or delegated. ``` ## What this change does On HTP, `ops_to_not_decompose` now estimates which ops stay on CPU and decomposes the kept-whole ones. - CPU ops: ops whose dtypes or shapes rule QNN out, ops without a tensor output, and higher-order ops such as `cond`. - A reader is a QNN op that reads a runtime value. A constant-only kept-whole op is left on CPU when it reaches a QNN op through a CPU op before any reader. - If all readers are getitems of CPU ops, every kept-whole op stays on CPU. When such a getitem follows a QNN op, ops not linked to a reader through QNN ops stay on CPU. - Quantize and dequantize ops, and ops reading a dequantize, stay whole, keeping their encodings. It may decompose an op a delegated partition still takes. Still failing as before: a quantized kept-whole op, an op cut off only by a skip or an op QNN rejects or removes, ops QNN rebuilds (`rms_norm`, `pixel_unshuffle`), ops exir cannot decompose (`elu`, `scatter` variants), and ops inside control-flow bodies. ## uint8 casts On the HTP simulator, a uint8 to float32 cast turned every nonzero input into 1. Now an unquantized one on HTP goes through int32, also for float64. Float16 and GPU casts are unchanged. ## Other fixes - From #24316: the name collision test logs cases that no longer collide, and skips only when none collide; a torch-only test checks `reserve_node_names`. - The QNN pass test job now finds the SDK libraries and, with `QNN_SDK_ROOT` set, fails when the backend does not load. - The rework draw-graph golden file has the permutes that appear since #21932. ## Tests - 6 filter tests cover each rule above, the lowered program, GPU, and skips; each fails without its rule. - With the real SDK, HTP takes a decomposed linear's pieces, and 6 quantized models stay fully delegated. - A builder test checks that only HTP adds the int32 step. `test_qnn_backend_cast` covers uint8 to float32 and float16 on the x86 HTP simulator (SM8650, QNN SDK 2.51), not on a device. - All pass on Linux x86_64 with torch 2.16 nightly. cc @cbilgin @psiddh --- .github/workflows/pull.yml | 4 + backends/qualcomm/builders/node_visitor.py | 2 + .../qualcomm/builders/node_visitor_manager.py | 8 +- backends/qualcomm/builders/op_to.py | 27 +- .../qualcomm/partition/qnn_partitioner.py | 182 ++++- backends/qualcomm/partition/utils.py | 26 +- backends/qualcomm/qnn_preprocess.py | 1 + backends/qualcomm/tests/models.py | 9 + backends/qualcomm/tests/rework/src/utils.py | 51 +- backends/qualcomm/tests/test_passes.py | 643 +++++++++++++++++- backends/qualcomm/tests/test_qnn_delegate.py | 33 +- backends/transforms/targets.bzl | 11 + .../test/test_reserve_node_names.py | 39 ++ exir/backend/partitioner.py | 13 + 14 files changed, 983 insertions(+), 66 deletions(-) create mode 100644 backends/transforms/test/test_reserve_node_names.py diff --git a/.github/workflows/pull.yml b/.github/workflows/pull.yml index 10bc81afac3..947e1970eee 100644 --- a/.github/workflows/pull.yml +++ b/.github/workflows/pull.yml @@ -1326,6 +1326,10 @@ jobs: # test_mha_to_sha imports oss_scripts/llama which transitively needs torchtune pip install -r requirements-examples.txt + # The QNN manager loads the SDK libraries by name. Without this, every test + # that needs the real backend fails. + export LD_LIBRARY_PATH="$QNN_SDK_ROOT/lib/x86_64-linux-clang/:${LD_LIBRARY_PATH:-}" + # Run QNN pass unit tests pytest -xvs backends/qualcomm/tests/test_passes.py diff --git a/backends/qualcomm/builders/node_visitor.py b/backends/qualcomm/builders/node_visitor.py index 02fbe08e8b0..a416f62c329 100644 --- a/backends/qualcomm/builders/node_visitor.py +++ b/backends/qualcomm/builders/node_visitor.py @@ -137,6 +137,7 @@ def __init__( edge_program: torch.export.ExportedProgram, enable_tensor_dump, is_qnn_partitioner=True, + backend_type=None, ) -> None: # is_qnn_partitioner: True for the real qnn_partitioner / qnn_preprocess # flows, where external_ids is built from the program being processed so @@ -149,6 +150,7 @@ def __init__( self.edge_program = edge_program self.enable_tensor_dump = enable_tensor_dump self.is_qnn_partitioner = is_qnn_partitioner + self.backend_type = backend_type def get_node(self, node): """ diff --git a/backends/qualcomm/builders/node_visitor_manager.py b/backends/qualcomm/builders/node_visitor_manager.py index f866c900a6c..f4c5a7c9f6b 100644 --- a/backends/qualcomm/builders/node_visitor_manager.py +++ b/backends/qualcomm/builders/node_visitor_manager.py @@ -54,6 +54,7 @@ def get_node_visitors( enable_tensor_dump=False, op_package_infos: List[QnnExecuTorchOpPackageInfo] = None, is_qnn_partitioner=True, + backend_type=None, ) -> Dict[str, NodeVisitor]: """Create a new class instance at runtime, and put them in a dict""" node_to_external_map = generate_node_to_external_map(edge_program) @@ -63,7 +64,11 @@ def get_node_visitors( visitor ), f"Expecting a callable class, but got {visitor} of type {type(visitor)}" node_visitors[target] = visitor( - node_to_external_map, edge_program, enable_tensor_dump, is_qnn_partitioner + node_to_external_map, + edge_program, + enable_tensor_dump, + is_qnn_partitioner, + backend_type, ) if op_package_infos: custom_ops = [] @@ -75,6 +80,7 @@ def get_node_visitors( edge_program, enable_tensor_dump, is_qnn_partitioner, + backend_type, ) node_visitors[op_package_info.custom_op_name] = custom_op_builder custom_ops.append(op_package_info.custom_op_name) diff --git a/backends/qualcomm/builders/op_to.py b/backends/qualcomm/builders/op_to.py index 9b024307934..b6e3ad9c824 100644 --- a/backends/qualcomm/builders/op_to.py +++ b/backends/qualcomm/builders/op_to.py @@ -8,6 +8,9 @@ import executorch.backends.qualcomm.python.PyQnnManagerAdaptor as PyQnnManager import torch +from executorch.backends.qualcomm.serialization.qc_schema import ( + QnnExecuTorchBackendType, +) from executorch.backends.qualcomm.utils.constants import QCOM_QUANT_ATTRS from .node_visitor import NodeVisitor, QNN_TENSOR_TYPE_MAP @@ -97,13 +100,27 @@ def define_node( # since int32 is the only source that can be cast into int64 # this is mainly for validation purpose, redundant cast ops will be fused # in preprocess stage. + # HTP casts uint8 to float32 as if the input were bool, while uint8 to + # int32 and int32 to float32 are right, so on HTP a plain uint8 to + # float32 cast goes through int32 too, as does float64, which is built + # as float32. Here the step is needed for the values, and nothing fuses + # it away. ops = [] if ( - node.meta["val"].dtype == torch.int64 - and input_node.meta["val"].dtype != torch.int32 - ) or ( - input_node.meta["val"].dtype == torch.int64 - and node.meta["val"].dtype != torch.int32 + ( + node.meta["val"].dtype == torch.int64 + and input_node.meta["val"].dtype != torch.int32 + ) + or ( + input_node.meta["val"].dtype == torch.int64 + and node.meta["val"].dtype != torch.int32 + ) + or ( + input_node.meta["val"].dtype == torch.uint8 + and node.meta["val"].dtype in (torch.float32, torch.float64) + and not node.meta.get(QCOM_QUANT_ATTRS) + and self.backend_type == QnnExecuTorchBackendType.kHtpBackend + ) ): input_quant_encoding, input_quant_configs = self.get_quant_encoding_conf( input_node, node diff --git a/backends/qualcomm/partition/qnn_partitioner.py b/backends/qualcomm/partition/qnn_partitioner.py index 46c853331c6..29db56d985e 100644 --- a/backends/qualcomm/partition/qnn_partitioner.py +++ b/backends/qualcomm/partition/qnn_partitioner.py @@ -4,9 +4,11 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. import copy +import functools import logging +import operator from collections import defaultdict -from typing import Any, Callable, Dict, List, Optional, Tuple +from typing import Any, Callable, Dict, List, Optional, Set, Tuple import torch from executorch.backends.qualcomm.builders import node_visitor_manager @@ -14,10 +16,7 @@ PER_CHANNEL_GROUP_ENCODING, ) from executorch.backends.qualcomm.builders.qnn_constants import OpContextLoader -from executorch.backends.qualcomm.builders.utils import ( - is_mutable_buffer_input, - is_parameter, -) +from executorch.backends.qualcomm.builders.utils import is_parameter from executorch.backends.qualcomm.qnn_preprocess import QnnBackend from executorch.backends.qualcomm.serialization.qc_schema import ( HtpArch, @@ -46,9 +45,12 @@ ) from executorch.exir.backend.utils import tag_constant_data, tag_mutated_buffer from executorch.exir.dialects._ops import ops as exir_ops +from executorch.exir.tracer import _default_decomposition_table from torch.export.exported_program import ExportedProgram +from torch.export.graph_signature import InputKind from torch.fx.passes.infra.partitioner import Partition from torch.fx.passes.operator_support import OperatorSupportBase +from torch.utils import _pytree as pytree from .common_defs import ( allow_list_operator, @@ -62,6 +64,8 @@ get_skip_decomp_table, has_data_dependent_shape, has_unsupported_dtype, + is_quantized, + must_stay_on_cpu, warn_if_dilated_conv_may_not_fit_vtcm, ) @@ -92,6 +96,7 @@ def __init__( edge_program, op_package_infos=python_options.op_package_options.op_package_infos, is_qnn_partitioner=is_qnn_partitioner, + backend_type=python_options.backend_options.backend_type, ) self.skip_node_op_set = skip_node_op_set @@ -327,6 +332,26 @@ def _drop_orphaned_parameter_dequants( ) return [partition for partition in partitions if partition.nodes] + @staticmethod + def _constant_inputs(edge_program: torch.export.ExportedProgram) -> Set[str]: + """ + Names of the inputs that hold the same value on every run: parameters, + buffers that are never mutated, and lifted constants. + """ + signature = edge_program.graph_signature + mutated = set(signature.buffers_to_mutate.values()) + return { + spec.arg.name + for spec in signature.input_specs + if spec.kind + in (InputKind.PARAMETER, InputKind.BUFFER, InputKind.CONSTANT_TENSOR) + and spec.target not in mutated + } + + @staticmethod + def _is_runtime_input(node: torch.fx.Node, constant_inputs: Set[str]) -> bool: + return node.op != "get_attr" and node.name not in constant_inputs + @staticmethod def _drop_partitions_without_runtime_inputs( partitions: List[Partition], edge_program: torch.export.ExportedProgram @@ -336,23 +361,113 @@ def _drop_partitions_without_runtime_inputs( only by constants once a CPU op splits it from the rest. Such a partition stays on CPU. """ - - def is_runtime_input(node: torch.fx.Node) -> bool: - return not is_parameter(node, edge_program) or is_mutable_buffer_input( - node, edge_program - ) - + constant_inputs = QnnPartitioner._constant_inputs(edge_program) return [ partition for partition in partitions if any( - is_runtime_input(arg) + QnnPartitioner._is_runtime_input(arg, constant_inputs) for node in partition.nodes for arg in node.all_input_nodes if arg not in partition.nodes ) ] + @staticmethod + def _may_run_on_qnn(node: torch.fx.Node) -> bool: + """ + Whether QNN's op check may accept the node, as far as can be told + before the kept-whole ops are decomposed. Ops whose dtypes or shapes + rule QNN out, ops without a tensor output, such as the runtime + asserts export adds, and higher-order ops, which have no node visitor, + stay on CPU. Every getitem is accepted, as in QNN's op check. + """ + if node.op != "call_function": + return False + if node.target in allow_list_operator: + return True + if not isinstance(node.target, torch._ops.OpOverload): + return False + # Retracing can drop the "val" of an assert, but not its schema. + if not node.target._schema.returns: + return False + if "val" in node.meta and not any( + isinstance(val, torch.Tensor) + for val in pytree.tree_leaves(node.meta["val"]) + ): + return False + return not must_stay_on_cpu(node) + + @staticmethod + def _left_on_cpu(ep: ExportedProgram) -> Set[torch.fx.Node]: + """ + Estimates, on the ATen program, which ops HTP leaves on CPU, so that + the kept-whole ones among them can be decomposed. + """ + + # Partitioning here would take time quadratic in the graph size, so + # only the ops that _may_run_on_qnn rules out count as CPU ops. A QNN + # op that reads no runtime value is left on CPU when: + # - it reaches a QNN op through a CPU op before any QNN op that reads + # a runtime value, since the partitioner may split it off to avoid a + # cycle; + # - the only QNN ops that read a runtime value are getitems of CPU + # ops, since the partitioner moves those getitems out of their + # partitions; + # - such a getitem follows a QNN op, and no chain of QNN ops links the + # op to a QNN op that reads a runtime value. + # Skips are left out, since a skip that changed what is decomposed + # would rename later nodes, so a skip_node_id_set entry could name a + # different node. + # Ops that QNN rejects or removes later are not known here either. So + # an op cut off only by them still fails as before. + # An op can also be decomposed although a partition that reads a + # runtime value takes it. + nodes = list(ep.graph.nodes) + qnn = set(filter(QnnPartitioner._may_run_on_qnn, nodes)) + constant_inputs = QnnPartitioner._constant_inputs(ep) + runtime, after_qnn = set(), set() + for node in nodes: + if node in qnn or not after_qnn.isdisjoint(node.all_input_nodes): + after_qnn.add(node) + if node in qnn and any( + ( + arg in runtime + if arg in qnn + else QnnPartitioner._is_runtime_input(arg, constant_inputs) + ) + for arg in node.all_input_nodes + ): + runtime.add(node) + to_qnn, before_cpu = set(), set() + for node in reversed(nodes): + if node in qnn or not to_qnn.isdisjoint(node.users): + to_qnn.add(node) + if any( + (user in before_cpu and user not in runtime) + or (user in to_qnn and user not in qnn) + for user in node.users + ): + before_cpu.add(node) + left_on_cpu = (qnn - runtime) & before_cpu + cpu_getitems = { + node + for node in runtime + if node.target is operator.getitem and node.args[0] not in qnn + } + if runtime <= cpu_getitems: + return qnn - runtime + if not any(node.args[0] in after_qnn for node in cpu_getitems): + return left_on_cpu + linked, stack = set(runtime), list(runtime) + while stack: + node = stack.pop() + for neighbor in (*node.all_input_nodes, *node.users): + if neighbor in qnn and neighbor not in linked: + linked.add(neighbor) + stack.append(neighbor) + return left_on_cpu | (qnn - runtime - linked) + def tag_nodes( self, partitions: List[Partition], edge_program: torch.export.ExportedProgram ) -> None: @@ -414,6 +529,47 @@ def ops_to_not_decompose( Determines which op should not be decomposed during partitioning. The list of operators is obtained from `get_skip_decomp_table()`. The filter function (`filter_fn`) can be used to further refine which nodes are not decomposed. (advanced use case) + On HTP, kept-whole ops that `_drop_partitions_without_runtime_inputs` + would leave on CPU, as far as can be told before they are decomposed, + are decomposed too. Quantized ops stay whole, and exir cannot + decompose some ops, such as elu and the scatter variants, so such an op + still fails as before. """ do_not_decompose = get_skip_decomp_table() - return (do_not_decompose, filter_fn) + # On the lowered program exir reports every kept-whole op left there + # that the filter accepts, so it stays plain filter_fn. + if self.backend != QnnExecuTorchBackendType.kHtpBackend or ep.dialect == "EDGE": + return (do_not_decompose, filter_fn) + + # exir calls this method several times on the ATen program but uses + # only one of the filters, so the estimate runs on first use. That is + # before exir renames the targets of the ops it keeps whole, which + # would hide the dequantize ops. + # Quantized ops stay whole, in case the estimate is wrong: their pieces + # would have no quantization encodings, so QNN would reject them where + # a partition would take them. + @functools.cache + def decomposed(): + nodes = {node for node in self._left_on_cpu(ep) if not is_quantized(node)} + decompositions = _default_decomposition_table() + names = sorted( + node.name + for node in nodes + if node.target in do_not_decompose + and node.target in decompositions + and filter_fn(node) + ) + message = f"[QnnPartitioner] Decomposing kept-whole ops HTP would leave on CPU: {names}" + if names and self.skip_node_id_set: + logger.warning( + f"{message}. Decomposing renames later nodes, so check that " + "skip_node_id_set still names the intended nodes." + ) + elif names: + logger.info(message) + return nodes + + return ( + do_not_decompose, + lambda node: filter_fn(node) and node not in decomposed(), + ) diff --git a/backends/qualcomm/partition/utils.py b/backends/qualcomm/partition/utils.py index a608cc42960..015c69e16da 100644 --- a/backends/qualcomm/partition/utils.py +++ b/backends/qualcomm/partition/utils.py @@ -199,12 +199,32 @@ def has_data_dependent_shape(node: torch.fx.Node) -> bool: ) +def must_stay_on_cpu(node: torch.fx.Node) -> bool: + """ + Whether the node's data types or shapes keep it off QNN. Both are already + known before decomposition. + """ + return ( + has_unsupported_dtype(node) and not _unsupported_dtypes_are_quantized(node) + ) or has_data_dependent_shape(node) + + +def is_quantized(node: torch.fx.Node) -> bool: + """ + Whether the node quantizes, dequantizes, or reads the output of a + dequantize op. Decomposing such a node drops its quantization encodings. + """ + return ( + node.target in _Q_OPS + or node.target in _DQ_OPS + or any(arg.target in _DQ_OPS for arg in node.all_input_nodes) + ) + + # Logic to determine whether to skip decompose and has higher priority than get_skip_decomp_table() def filter_fn(node: torch.fx.Node) -> bool: # The node will stay on CPU, so it must not be kept from decomposition. - if has_unsupported_dtype(node) and not _unsupported_dtypes_are_quantized(node): - return False - if has_data_dependent_shape(node): + if must_stay_on_cpu(node): return False # QNN does not support int32/int64 IO for the following OPs. potential_i32_i64_io_ops = [ diff --git a/backends/qualcomm/qnn_preprocess.py b/backends/qualcomm/qnn_preprocess.py index dda4687e59c..9e9bb112916 100644 --- a/backends/qualcomm/qnn_preprocess.py +++ b/backends/qualcomm/qnn_preprocess.py @@ -108,6 +108,7 @@ def _build_op_wrappers( edge_program, enable_tensor_dump=enable_tensor_dump, op_package_infos=op_package_infos, + backend_type=backend_type, ) py_op_wrapper_list = [] for node in graph_module.graph.nodes: diff --git a/backends/qualcomm/tests/models.py b/backends/qualcomm/tests/models.py index 926f5e1fb67..61882533093 100644 --- a/backends/qualcomm/tests/models.py +++ b/backends/qualcomm/tests/models.py @@ -402,6 +402,15 @@ def forward(self, x, y): return res + index.to(torch.int32) +class CastToFloat(torch.nn.Module): + def __init__(self, dtype=torch.float32): + super().__init__() + self.dtype = dtype + + def forward(self, x): + return x.to(self.dtype) / 255 + + class Cat2(torch.nn.Module): def __init__(self): super().__init__() diff --git a/backends/qualcomm/tests/rework/src/utils.py b/backends/qualcomm/tests/rework/src/utils.py index 9b564f83a7c..1188183ab84 100644 --- a/backends/qualcomm/tests/rework/src/utils.py +++ b/backends/qualcomm/tests/rework/src/utils.py @@ -36,6 +36,9 @@ from executorch.backends.qualcomm.partition.utils import ( warn_if_dilated_conv_may_not_fit_vtcm, ) +from executorch.backends.qualcomm.serialization.qc_schema import ( + QnnExecuTorchBackendType, +) from executorch.backends.qualcomm.tests.rework.conftest import ( calibrate, check_exception, @@ -177,6 +180,14 @@ class DrawGraph: dims: [1, 4, 8, 8] quantization_encoding: Qnn_QuantizationEncoding_t.QNN_QUANTIZATION_ENCODING_SCALE_OFFSET > color=black fillcolor=transparent shape=box style=rounded] + "aten_permute_copy_default@0" [label=< + + + + + + +
name: aten_permute_copy_default@0
data_type: Qnn_DataType_t.QNN_DATATYPE_UFIXED_POINT_8
tensor_type: Qnn_TensorType_t.QNN_TENSOR_TYPE_NATIVE
dims: [1, 8, 8, 4]
quantization_encoding: Qnn_QuantizationEncoding_t.QNN_QUANTIZATION_ENCODING_SCALE_OFFSET
> color=black fillcolor=transparent shape=box style=rounded] "input_1_y@0" [label=< @@ -193,6 +204,14 @@ class DrawGraph:
name: input_1_y@0
dims: [1, 4, 8, 8]
quantization_encoding: Qnn_QuantizationEncoding_t.QNN_QUANTIZATION_ENCODING_SCALE_OFFSET
> color=black fillcolor=transparent shape=box style=rounded] + "aten_permute_copy_default_1@0" [label=< + + + + + + +
name: aten_permute_copy_default_1@0
data_type: Qnn_DataType_t.QNN_DATATYPE_UFIXED_POINT_8
tensor_type: Qnn_TensorType_t.QNN_TENSOR_TYPE_NATIVE
dims: [1, 8, 8, 4]
quantization_encoding: Qnn_QuantizationEncoding_t.QNN_QUANTIZATION_ENCODING_SCALE_OFFSET
> color=black fillcolor=transparent shape=box style=rounded] "b__frozen_param0@0" [label=< @@ -225,6 +244,14 @@ class DrawGraph:
name: b__frozen_param0@0
dims: [1, 8, 8, 4]
quantization_encoding: Qnn_QuantizationEncoding_t.QNN_QUANTIZATION_ENCODING_SCALE_OFFSET
> color=black fillcolor=transparent shape=box style=rounded] + "aten_permute_copy_default_3@0" [label=< + + + + + + +
name: aten_permute_copy_default_3@0
data_type: Qnn_DataType_t.QNN_DATATYPE_UFIXED_POINT_8
tensor_type: Qnn_TensorType_t.QNN_TENSOR_TYPE_NATIVE
dims: [1, 4, 8, 8]
quantization_encoding: Qnn_QuantizationEncoding_t.QNN_QUANTIZATION_ENCODING_SCALE_OFFSET
> color=black fillcolor=transparent shape=box style=rounded] "output_quantized_decomposed_dequantize_per_tensor_default@0" [label=< @@ -297,6 +324,14 @@ class DrawGraph:
name: output_quantized_decomposed_dequantize_per_tensor_default@0
dims: [1, 8, 8, 4]
quantization_encoding: Qnn_QuantizationEncoding_t.QNN_QUANTIZATION_ENCODING_SCALE_OFFSET
> color=black fillcolor=transparent shape=box style=rounded] + "aten_permute_copy_default_2@0" [label=< + + + + + + +
name: aten_permute_copy_default_2@0
data_type: Qnn_DataType_t.QNN_DATATYPE_UFIXED_POINT_8
tensor_type: Qnn_TensorType_t.QNN_TENSOR_TYPE_NATIVE
dims: [1, 4, 8, 8]
quantization_encoding: Qnn_QuantizationEncoding_t.QNN_QUANTIZATION_ENCODING_SCALE_OFFSET
> color=black fillcolor=transparent shape=box style=rounded] "output_quantized_decomposed_dequantize_per_tensor_default_1@0" [label=< @@ -306,13 +341,16 @@ class DrawGraph:
name: output_quantized_decomposed_dequantize_per_tensor_default_1@0
quantization_encoding: Qnn_QuantizationEncoding_t.QNN_QUANTIZATION_ENCODING_UNDEFINED
> color=black fillcolor=transparent shape=box style=rounded] "input_0_x@0" -> "quantized_decomposed_quantize_per_tensor_default@0" + "quantized_decomposed_quantize_per_tensor_default@0" -> "aten_permute_copy_default@0" "input_1_y@0" -> "quantized_decomposed_quantize_per_tensor_default_1@0" - "quantized_decomposed_quantize_per_tensor_default@0" -> "aten_convolution_default@0" + "quantized_decomposed_quantize_per_tensor_default_1@0" -> "aten_permute_copy_default_1@0" + "aten_permute_copy_default@0" -> "aten_convolution_default@0" "b__frozen_param0@0" -> "aten_convolution_default@0" "b__frozen_param1@0" -> "aten_convolution_default@0" "aten_convolution_default@0" -> "aten_relu_default@0" - "aten_relu_default@0" -> "output_quantized_decomposed_dequantize_per_tensor_default@0" - "quantized_decomposed_quantize_per_tensor_default_1@0" -> "aten_convolution_default_1@0" + "aten_relu_default@0" -> "aten_permute_copy_default_3@0" + "aten_permute_copy_default_3@0" -> "output_quantized_decomposed_dequantize_per_tensor_default@0" + "aten_permute_copy_default_1@0" -> "aten_convolution_default_1@0" "b__frozen_param2@0" -> "aten_convolution_default_1@0" "b__frozen_param3@0" -> "aten_convolution_default_1@0" "aten_convolution_default_1@0" -> "aten_relu_default_1@0" @@ -321,7 +359,8 @@ class DrawGraph: "aten_add_tensor@0" -> "aten_convolution_default_2@0" "b__frozen_param4@0" -> "aten_convolution_default_2@0" "b__frozen_param5@0" -> "aten_convolution_default_2@0" - "aten_convolution_default_2@0" -> "output_quantized_decomposed_dequantize_per_tensor_default_1@0" + "aten_convolution_default_2@0" -> "aten_permute_copy_default_2@0" + "aten_permute_copy_default_2@0" -> "output_quantized_decomposed_dequantize_per_tensor_default_1@0" } """ @@ -333,7 +372,9 @@ def _build_op_wrapper_list(module, inputs): ) nodes_to_wrappers = defaultdict(dict) node_visitors = get_node_visitors( - delegated_program.exported_program, enable_tensor_dump=False + delegated_program.exported_program, + enable_tensor_dump=False, + backend_type=QnnExecuTorchBackendType.kHtpBackend, ) py_op_wrapper_list = [] for node in graph_module.graph.nodes: diff --git a/backends/qualcomm/tests/test_passes.py b/backends/qualcomm/tests/test_passes.py index a607bcb4d81..5f1ab612cf4 100644 --- a/backends/qualcomm/tests/test_passes.py +++ b/backends/qualcomm/tests/test_passes.py @@ -1,7 +1,10 @@ +import contextlib +import logging import operator +import os import unittest from collections import defaultdict -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, patch, PropertyMock import torch from executorch.backends.qualcomm._passes import ( @@ -35,8 +38,14 @@ ) from executorch.backends.qualcomm.builders.qnn_constants import OpContextLoader from executorch.backends.qualcomm.builders.utils import is_parameter -from executorch.backends.qualcomm.partition.qnn_partitioner import QnnOperatorSupport -from executorch.backends.qualcomm.partition.utils import get_skip_decomp_table +from executorch.backends.qualcomm.partition.qnn_partitioner import ( + QnnOperatorSupport, + QnnPartitioner, +) +from executorch.backends.qualcomm.partition.utils import ( + filter_fn, + get_skip_decomp_table, +) from executorch.backends.qualcomm.qnn_preprocess import QnnBackend from executorch.backends.qualcomm.quantizer.quantizer import QnnQuantizer, QuantDtype from executorch.backends.qualcomm.serialization.qc_schema import ( @@ -46,6 +55,7 @@ ) from executorch.backends.qualcomm.tests.models import ( BroadcastAndMutate, + CastToFloat, ConvBackward, HardSigmoid, Reciprocal, @@ -53,25 +63,295 @@ Unfold, ) from executorch.backends.qualcomm.utils.constants import ( + QCOM_DTYPE, QCOM_ENCODING, QCOM_QUANT_ATTRS, + QCOM_QUANT_MAX, + QCOM_QUANT_MIN, QCOM_SCALE, + QCOM_ZERO_POINT, QCOM_ZERO_POINTS, ) +from executorch.backends.qualcomm.utils.qnn_manager_lifecycle import QnnManagerRegistry from executorch.backends.qualcomm.utils.utils import ( + generate_gpu_compiler_spec, generate_htp_compiler_spec, generate_qnn_executorch_compiler_spec, to_edge_transform_and_lower_to_qnn, ) from executorch.exir import EdgeCompileConfig, to_edge +from executorch.exir.backend.backend_details import PreprocessResult from executorch.exir.debug_handle_utils import DEBUG_HANDLE_KEY from executorch.exir.dialects._ops import ops as exir_ops +from executorch.exir.lowered_backend_module import get_lowered_backend_modules +from executorch.exir.program._program import _replace_aten_ops_with_transformed_ops from torch.export.exported_program import OutputKind +from torch.export.graph_signature import ExportGraphSignature +from torch.fx.passes.infra.partitioner import CapabilityBasedPartitioner, Partition from torch.library import Library from torchao.quantization import IntxWeightOnlyConfig, PerAxis, quantize_ from torchao.quantization.pt2e.quantize_pt2e import convert_pt2e, prepare_pt2e +class ConstantBranches(torch.nn.Module): + def __init__(self): + super().__init__() + self.register_buffer("table", torch.randn(4, 16)) + self.register_buffer("weight", torch.randn(16, 16)) + self.register_buffer("mask", torch.rand(4, 16) > 0.5) + + def forward(self, x): + linear = torch.nn.functional.linear + # QNN has no bfloat16 type, and a masked select has no static shape, + # so these branches read nothing at runtime once those ops run on + # CPU. Their linears are decomposed. + cast = linear(self.table, self.weight).to(torch.bfloat16).float() + masked = linear(self.table, self.weight)[self.mask].sum() + # This linear feeds a delegated add both directly and through CPU + # casts, so the partitioner splits it off to avoid a cycle. It is + # decomposed. + detour = linear(self.table, self.weight) + detour = detour + detour.to(torch.bfloat16).float() + # This linear reads only constants too, but it shares a partition with + # the add that reads x, so it stays whole, even though that add + # reaches another QNN op through CPU casts. + delegated = linear(self.table, self.weight) + y = x + delegated + cast + masked + detour + return y.to(torch.bfloat16).float() + 1 + + +class MaskedReader(ConstantBranches): + def forward(self, x): + # Nothing after the masked select reaches QNN: the sum reads a + # data-dependent size, and the runtime asserts that export adds for + # that size have no tensor output. So the linear shares the add's + # partition and stays whole. + linear = torch.nn.functional.linear(self.table, self.weight) + return x + linear, linear[self.mask].sum() + + +class SkippedRelu(ConstantBranches): + def forward(self, x): + linear = torch.nn.functional.linear(self.table, self.weight) + return x + linear.relu() + + +class QuantizedCast(ConstantBranches): + def forward(self, x): + # A quantized linear stays whole. Quantized casts get a QNN type, so + # here the whole model is delegated. + linear = torch.nn.functional.linear(self.table, self.weight) + return x + linear.to(torch.bfloat16).float() + + +class NoRuntimeInput(torch.nn.Module): + def __init__(self): + super().__init__() + self.register_buffer("table", torch.randn(4, 16)) + self.register_buffer("weight", torch.randn(16, 16)) + + def forward(self, x): + # x only reaches a CPU cast, so no partition reads a runtime value, and + # the linear is decomposed. + linear = torch.nn.functional.linear(self.table, self.weight) + return x.to(torch.bfloat16), linear * 2 + + +class CastSplit(NoRuntimeInput): + def forward(self, x): + # QNN takes every getitem, so the getitems of this CPU split can join + # the add's partition. The linear would then close a cycle through the + # split, so it is left out and decomposed. + linear = torch.nn.functional.linear(self.table, self.weight) + low, high = linear.to(torch.bfloat16).split(8, dim=-1) + return x + linear, low, high + + +class ConstantOutput(NoRuntimeInput): + def forward(self, x): + # No path links this linear to the ops that read x, but the partitioner + # also merges such ops, so the linear stays whole. The getitems of the + # split stay with it, since QNN takes it. + linear = torch.nn.functional.linear(self.table, self.weight) + return x + 1, *x.split(8, dim=-1), linear + + +class CastSplitInput(NoRuntimeInput): + def forward(self, x): + # Only the getitems of a CPU split read x, and the partitioner moves + # them out of the linear's partition, so it reads no runtime value. + # The linear is decomposed. + low, high = x.to(torch.bfloat16).split(8, dim=-1) + return low, high, torch.nn.functional.linear(self.table, self.weight) + + +class SplitAfterAdd(NoRuntimeInput): + def forward(self, x): + # These getitems come after a QNN op, so their partition cannot merge + # with the add's. The linear joins theirs, which reads no runtime value + # once they are moved out, so it is decomposed. + low, high = (x + 1).to(torch.bfloat16).split(8, dim=-1) + return low, high, torch.nn.functional.linear(self.table, self.weight) + + +class CastSplitLinked(NoRuntimeInput): + def forward(self, x): + # As above, but the linear feeds the add, so it stays whole. + low, high = (x + 1).to(torch.bfloat16).split(8, dim=-1) + linear = torch.nn.functional.linear(self.table, self.weight) + return low, high, x + linear + + +class LinearOfInput(NoRuntimeInput): + def forward(self, x): + # This linear reads x through the add, so it stays whole even though it + # reaches a QNN op through CPU casts. + linear = torch.nn.functional.linear(x + 1, self.weight) + return linear + linear.to(torch.bfloat16).float() + + +class ReluBeforeCast(NoRuntimeInput): + def forward(self, x): + # The linear reaches the CPU cast through a relu that reads no runtime + # value either, so it is cut off and decomposed. + linear = torch.nn.functional.linear(self.table, self.weight) + return x + linear.relu().to(torch.bfloat16).float() + + +class CondAfterLinear(NoRuntimeInput): + def forward(self, x): + # QNN has no node visitor for cond, so it runs on CPU and cuts the + # linear off from the add. The linear is decomposed. + linear = torch.nn.functional.linear(self.table, self.weight) + branch = torch.cond(x.sum() > 0, torch.neg, torch.abs, (linear,)) + return x + branch + + +class ItemAfterLinear(NoRuntimeInput): + def forward(self, x): + # item has no tensor output, so it runs on CPU, and the partition of + # the linear and the sum reads no runtime value. The linear is + # decomposed. + linear = torch.nn.functional.linear(self.table, self.weight) + return x * linear.sum().item() + + +class IntStackBesideLinear(NoRuntimeInput): + def forward(self, x): + # Both stay on CPU, but filter_fn already decomposes an int64 stack. + linear = torch.nn.functional.linear(self.table, self.weight) + indices = torch.stack([self.table.long(), self.table.long()]) + return x.to(torch.bfloat16), linear * 2, indices + + +class EluBesideLinear(NoRuntimeInput): + def forward(self, x): + # Both stay on CPU, but exir has no decomposition for elu. + linear = torch.nn.functional.linear(self.table, self.weight) + elu = torch.nn.functional.elu(self.table) + return x.to(torch.bfloat16), linear * 2, elu * 2 + + +class SharedConstant(NoRuntimeInput): + def forward(self, x): + # Like CastSplitLinked, but the linear reads a value that the add + # reads. + low, high = (x + 1).to(torch.bfloat16).split(8, dim=-1) + table = self.table * 2 + linear = torch.nn.functional.linear(table, self.weight) + return low, high, x + table, linear + + +class UnlinkedSplit(NoRuntimeInput): + def forward(self, x): + # No QNN op comes before this split, so its getitems cannot keep the + # linear out of the add's partition. It stays whole. + low, high = x.to(torch.bfloat16).split(8, dim=-1) + linear = torch.nn.functional.linear(self.table, self.weight) + return x + 1, low, high, linear + + +class MutatedTable(NoRuntimeInput): + def forward(self, x): + # The table changes on every run, so the linear reads a runtime value + # and stays whole, although only a CPU cast reads it. + self.table.mul_(2) + linear = torch.nn.functional.linear(self.table, self.weight) + return x + linear.to(torch.bfloat16).float() + + +class QuantizedWeight(torch.nn.Module): + def __init__(self): + super().__init__() + self.register_buffer("table", torch.randn(4, 16)) + self.linear = torch.nn.Linear(16, 16, bias=False) + + def forward(self, x): + return x + self.linear(self.table).to(torch.bfloat16).float() + + +class GroupQuantizedWeight(NoRuntimeInput): + def __init__(self): + super().__init__() + self.register_buffer("scales", torch.full((16, 2), 0.1)) + self.register_buffer("zero_points", torch.zeros(16, 2, dtype=torch.int8)) + + def forward(self, x): + group = (self.scales, self.zero_points, -8, 7, torch.int8, 8) + weight = torch.ops.quantized_decomposed.quantize_per_channel_group( + self.weight, *group + ) + weight = torch.ops.quantized_decomposed.dequantize_per_channel_group( + weight, *group, torch.float32 + ) + linear = torch.nn.functional.linear(self.table, weight) + return x + linear.to(torch.bfloat16).float() + + +@contextlib.contextmanager +def stubbed_qnn_backend(): + # Lowers without the QNN SDK: the backend accepts every op and compiles + # nothing. + with patch("executorch.backends.qualcomm.utils.utils.setup_qnn_sdk"), patch.object( + QnnManagerRegistry, "get_or_create_qnn_manager", return_value=MagicMock() + ), patch.object( + QnnBackend, + "preprocess_multimethod", + side_effect=lambda programs, _: { + method: [PreprocessResult(processed_bytes=b"qnn")] * len(partitions) + for method, partitions in programs.items() + }, + ): + yield + + +# Read at import, because lowering can download the SDK and set QNN_SDK_ROOT +# for this process, too late for the loader to find the SDK libraries. +QNN_SDK_SET_UP = "QNN_SDK_ROOT" in os.environ + + +def skip_without_qnn_sdk(test, reason): + # With the SDK set up the backend has to load, so a failure to load is a + # real error that the caller reports, not a reason to skip. + if not QNN_SDK_SET_UP: + test.skipTest(f"QNN SDK not available: {reason}") + + +def stubbed_compiler_specs(backend_options): + with patch("executorch.backends.qualcomm.utils.utils.setup_qnn_sdk"): + return generate_qnn_executorch_compiler_spec( + soc_model=QcomChipset.SM8650, backend_options=backend_options + ) + + +def delegated_ops(edge): + return [ + node.target + for lowered in get_lowered_backend_modules(edge.exported_program().graph_module) + for node in lowered.original_module.graph.nodes + ] + + class TestPasses(unittest.TestCase): def _build_context_loader_edge_program(self, op_name, check_ir_validity=True): graph_name = "forward" @@ -257,17 +537,22 @@ def forward(self, input, k, v, mul): ).graph.lint() except Exception as e: # The pass manager wraps the lint error in its own. - self.assertIn("Node redefined name", str(e.__cause__ or e)) + if "Node redefined name" not in str(e.__cause__ or e): + raise else: no_collision.append(type(model).__name__) reserve.assert_called_once() - # A skip, not a failure: a torch update that removes the collision is fine. + # A skip, not a failure: a torch update that removes the collision is + # fine. While any case still collides, the test still means something. + message = ( + f"{no_collision} no longer collide without reserve_node_names, so " + "those cases no longer test it; see " + "https://github.com/pytorch/pytorch/issues/202927" + ) + if len(no_collision) == len(cases): + self.skipTest(message) if no_collision: - self.skipTest( - f"{no_collision} no longer collide without reserve_node_names, so " - "those cases no longer test it; see " - "https://github.com/pytorch/pytorch/issues/202927" - ) + logging.warning(message) def test_build_op_wrappers_returns_context_binary(self): op_name = "ctx_loader_build" @@ -764,9 +1049,49 @@ def forward(self, x, i): ) except RuntimeError as e: if "Failed to initialize QNN backend" in str(e): - self.skipTest(f"QNN SDK not available: {e}") + skip_without_qnn_sdk(self, e) raise + def test_uint8_to_float32_cast_goes_through_int32_only_on_htp(self): + quant_attrs = { + QCOM_ENCODING: exir_ops.edge.quantized_decomposed.quantize_per_tensor.default, + QCOM_SCALE: 1 / 255, + QCOM_ZERO_POINT: 0, + QCOM_QUANT_MIN: 0, + QCOM_QUANT_MAX: 255, + QCOM_DTYPE: torch.uint8, + } + cases = [ + (torch.float32, QnnExecuTorchBackendType.kHtpBackend, None, 2), + (torch.float64, QnnExecuTorchBackendType.kHtpBackend, None, 2), + (torch.float32, QnnExecuTorchBackendType.kGpuBackend, None, 1), + (torch.float16, QnnExecuTorchBackendType.kHtpBackend, None, 1), + (torch.float32, QnnExecuTorchBackendType.kHtpBackend, quant_attrs, 1), + ] + for dtype, backend_type, cast_quant_attrs, expected in cases: + with self.subTest( + dtype=dtype, backend_type=backend_type, quantized=bool(cast_quant_attrs) + ): + edge_program = to_edge( + torch.export.export( + CastToFloat(dtype), + (torch.arange(4, dtype=torch.uint8),), + strict=True, + ) + ).exported_program() + visitors = get_node_visitors(edge_program, backend_type=backend_type) + cast = next( + node + for node in edge_program.graph.nodes + if node.op == "call_function" and node.args[0].op == "placeholder" + ) + if cast_quant_attrs: + cast.meta[QCOM_QUANT_ATTRS] = cast_quant_attrs + ops = visitors[cast.target.__name__].define_node( + cast, defaultdict(dict) + ) + self.assertEqual(expected, len(ops)) + def test_mha_to_sha(self): from executorch.backends.qualcomm.utils.utils import convert_linear_to_conv2d from executorch.examples.models.llama.model_args import ModelArgs @@ -1022,7 +1347,7 @@ def test_resolve_debug_handle(self): ) except RuntimeError as e: if "QNN" in str(e) or "qnn" in str(e): - self.skipTest(f"QNN SDK not available: {e}") + skip_without_qnn_sdk(self, e) raise exec_prog_mgr = edge_prog_mgr.to_executorch() etrecord = exec_prog_mgr.get_etrecord() @@ -1161,7 +1486,8 @@ def forward(self, x): edge.to_executorch() except RuntimeError as e: if "QNN" in str(e) or "qnn" in str(e): - self.skipTest(f"QNN SDK not available: {e}") + skip_without_qnn_sdk(self, e) + raise def test_stack_outside_pack_ranks_is_decomposed(self): class Stack(torch.nn.Module): @@ -1182,7 +1508,7 @@ def forward(self, x): ) except RuntimeError as e: if "Failed to initialize QNN backend" in str(e): - self.skipTest(f"QNN SDK not available: {e}") + skip_without_qnn_sdk(self, e) raise def test_partitioner_keeps_constant_only_subgraph_on_cpu(self): @@ -1190,29 +1516,281 @@ class ConstantBranch(torch.nn.Module): def __init__(self): super().__init__() self.register_buffer("table", torch.randn(4, 16)) + self.register_buffer("weight", torch.randn(16, 16)) def forward(self, x): - return x + torch.relu(self.table * 2 - 1) + # With relu on CPU, table * 2 - 1 reads nothing at runtime, and + # with the bfloat16 cast on CPU, neither does the linear. So the + # linear must be decomposed for this to lower. + linear = torch.nn.functional.linear(self.table, self.weight) + cast = linear.to(torch.bfloat16).float() + return x + torch.relu(self.table * 2 - 1) + cast + + class DetourBranch(ConstantBranch): + def forward(self, x): + # The linear reaches the add only through CPU casts, so it is + # decomposed, but the partition of x + 1 takes its pieces. + linear = torch.nn.functional.linear(self.table, self.weight) + cast = (x + 1).to(torch.bfloat16).float() + return linear.to(torch.bfloat16).float() + cast sample_input = (torch.randn(4, 16),) compiler_specs = generate_qnn_executorch_compiler_spec( soc_model=QcomChipset.SM8650, backend_options=generate_htp_compiler_spec(use_fp16=True), ) - # With relu on CPU, table * 2 - 1 reads nothing at runtime. - try: + # HTP accepts the pieces of a decomposed linear that a partition takes. + for model, takes_pieces in ((ConstantBranch(), False), (DetourBranch(), True)): + with self.subTest(model=type(model).__name__): + try: + edge = to_edge_transform_and_lower_to_qnn( + model.eval(), + sample_input, + compiler_specs, + skip_node_op_set={"aten.relu.default"}, + ) + except RuntimeError as e: + if "Failed to initialize QNN backend" in str(e): + skip_without_qnn_sdk(self, e) + raise + edge.to_executorch() + self.assertEqual( + takes_pieces, exir_ops.edge.aten.mm.default in delegated_ops(edge) + ) + + def test_htp_decomposes_kept_whole_ops_left_on_cpu(self): + compiler_specs = stubbed_compiler_specs( + generate_htp_compiler_spec(use_fp16=True) + ) + sample_input = (torch.randn(4, 16),) + with stubbed_qnn_backend(): edge = to_edge_transform_and_lower_to_qnn( - ConstantBranch().eval(), - sample_input, - compiler_specs, - skip_node_op_set={"aten.relu.default"}, + ConstantBranches().eval(), sample_input, compiler_specs ) - except RuntimeError as e: - if "Failed to initialize QNN backend" in str(e): - self.skipTest(f"QNN SDK not available: {e}") - raise + for model in ( + NoRuntimeInput(), + CastSplit(), + CastSplitInput(), + SplitAfterAdd(), + ReluBeforeCast(), + ): + with self.subTest(model=type(model).__name__): + to_edge_transform_and_lower_to_qnn( + model.eval(), sample_input, compiler_specs + ) + top_level_ops = [ + node.target + for node in edge.exported_program().graph.nodes + if node.op == "call_function" + ] + self.assertNotIn(exir_ops.edge.aten.linear.default, top_level_ops) + self.assertIn(exir_ops.edge.aten.linear.default, delegated_ops(edge)) + self.assertIn(exir_ops.edge.aten.add.Tensor, delegated_ops(edge)) edge.to_executorch() + def test_htp_keeps_linked_kept_whole_ops_whole(self): + compiler_specs = stubbed_compiler_specs( + generate_htp_compiler_spec(use_fp16=True) + ) + sample_input = (torch.randn(4, 16),) + for model in ( + MaskedReader(), + ConstantOutput(), + CastSplitLinked(), + LinearOfInput(), + SharedConstant(), + UnlinkedSplit(), + MutatedTable(), + ): + with self.subTest(model=type(model).__name__), stubbed_qnn_backend(): + edge = to_edge_transform_and_lower_to_qnn( + model.eval(), sample_input, compiler_specs + ) + self.assertIn(exir_ops.edge.aten.linear.default, delegated_ops(edge)) + + def test_htp_keeps_quantized_ops_whole(self): + compiler_specs = stubbed_compiler_specs( + generate_htp_compiler_spec(use_fp16=True) + ) + sample_input = (torch.randn(4, 16),) + quantized = prepare_pt2e( + torch.export.export(QuantizedCast().eval(), sample_input).module(), + QnnQuantizer(), + ) + quantized(*sample_input) + with stubbed_qnn_backend(): + edge = to_edge_transform_and_lower_to_qnn( + convert_pt2e(quantized), + sample_input, + stubbed_compiler_specs(generate_htp_compiler_spec(use_fp16=False)), + ) + self.assertIn(exir_ops.edge.aten.linear.default, delegated_ops(edge)) + + # These ops are left on CPU, but stay whole. exir renames each op it + # keeps whole while it filters, so the linear must still count as + # quantized once the dequantize of its weight is renamed. + weight_only = QuantizedWeight().eval() + quantize_( + weight_only, + IntxWeightOnlyConfig(weight_dtype=torch.int8, granularity=PerAxis(0)), + ) + for model, quantized_ops in ( + (weight_only, {torch.ops.torchao.dequantize_affine.default}), + ( + GroupQuantizedWeight().eval(), + { + torch.ops.quantized_decomposed.quantize_per_channel_group.default, + torch.ops.quantized_decomposed.dequantize_per_channel_group.default, + }, + ), + ): + with self.subTest(model=type(model).__name__): + kept_whole_ops = _replace_aten_ops_with_transformed_ops( + "forward", + torch.export.export(model, sample_input), + {"forward": [QnnPartitioner(compiler_specs)]}, + ) + self.assertLessEqual( + {torch.ops.aten.linear.default, *quantized_ops}, kept_whole_ops + ) + + def test_kept_whole_filter_is_filter_fn_on_edge_and_gpu(self): + sample_input = (torch.randn(4, 16),) + compiler_specs = stubbed_compiler_specs( + generate_htp_compiler_spec(use_fp16=True) + ) + with stubbed_qnn_backend(): + edge = to_edge_transform_and_lower_to_qnn( + ConstantBranches().eval(), sample_input, compiler_specs + ) + # On the lowered program the filter must not let a kept-whole op left + # on CPU through, so exir still reports it. + _, keep_whole = QnnPartitioner(compiler_specs).ops_to_not_decompose( + edge.exported_program() + ) + self.assertIs(keep_whole, filter_fn) + # GPU drops no partitions, so it keeps the same ops whole as before. + _, keep_whole = QnnPartitioner( + stubbed_compiler_specs(generate_gpu_compiler_spec()) + ).ops_to_not_decompose( + torch.export.export(ConstantBranches().eval(), sample_input) + ) + self.assertIs(keep_whole, filter_fn) + + def test_htp_kept_whole_filter_ignores_skips(self): + # A skip of either kind that changed what is decomposed would rename + # later nodes, so a skip_node_id_set entry could name a different node. + # Skipping the relu cuts the linear off, so the model cannot be lowered. + compiler_specs = stubbed_compiler_specs( + generate_htp_compiler_spec(use_fp16=True) + ) + ep = torch.export.export(SkippedRelu().eval(), (torch.randn(4, 16),)) + _, keep_whole = QnnPartitioner(compiler_specs).ops_to_not_decompose(ep) + _, keep_whole_skipped = QnnPartitioner( + compiler_specs, skip_node_op_set={"aten.relu.default"} + ).ops_to_not_decompose(ep) + self.assertEqual( + [keep_whole(node) for node in ep.graph.nodes], + [keep_whole_skipped(node) for node in ep.graph.nodes], + ) + + def test_htp_kept_whole_filter_counts_cond_and_item_as_cpu(self): + # QNN's passes cannot lower either model yet, so the filter is checked + # alone. + compiler_specs = stubbed_compiler_specs( + generate_htp_compiler_spec(use_fp16=True) + ) + # With a skip_node_id_set, the renaming is worth a warning. + for model, skip_node_id_set, level in ( + (CondAfterLinear(), None, "INFO"), + (ItemAfterLinear(), {"linear"}, "WARNING"), + ): + with self.subTest(model=type(model).__name__): + ep = torch.export.export(model.eval(), (torch.randn(4, 16),)) + _, keep_whole = QnnPartitioner( + compiler_specs, skip_node_id_set=skip_node_id_set + ).ops_to_not_decompose(ep) + linear = next( + node + for node in ep.graph.nodes + if node.target == torch.ops.aten.linear.default + ) + with self.assertLogs(QnnPartitioner.__module__, "INFO") as logs: + self.assertFalse(keep_whole(linear)) + self.assertEqual(level, logs.records[-1].levelname) + self.assertIn(linear.name, logs.output[-1]) + + def test_htp_kept_whole_log_names_only_newly_decomposed_ops(self): + compiler_specs = stubbed_compiler_specs( + generate_htp_compiler_spec(use_fp16=True) + ) + cases = [ + (IntStackBesideLinear(), torch.ops.aten.stack.default), + (EluBesideLinear(), torch.ops.aten.elu.default), + ] + for model, left_out in cases: + with self.subTest(left_out=left_out): + ep = torch.export.export(model.eval(), (torch.randn(4, 16),)) + _, keep_whole = QnnPartitioner(compiler_specs).ops_to_not_decompose(ep) + nodes = {node.target: node for node in ep.graph.nodes} + linear = nodes[torch.ops.aten.linear.default] + with self.assertLogs(QnnPartitioner.__module__, "INFO") as logs: + self.assertFalse(keep_whole(linear)) + self.assertIn(f"'{linear.name}'", logs.output[-1]) + self.assertNotIn(f"'{nodes[left_out].name}'", logs.output[-1]) + + def test_htp_kept_whole_estimate_avoids_quadratic_steps(self): + class Chain(torch.nn.Module): + def __init__(self, length): + super().__init__() + self.scales = torch.nn.ParameterList( + torch.rand(16) for _ in range(length) + ) + + def forward(self, x): + # Each scale * 2 reads only constants, like the dequantize of a + # quantized weight. + for scale in self.scales: + x = torch.relu(x * (scale * 2) + x) + return x + + compiler_specs = stubbed_compiler_specs( + generate_htp_compiler_spec(use_fp16=True) + ) + + # Large models have tens of thousands of nodes and constants. The time + # to partition grows with the square of the graph size, and so does + # going over the input specs of the graph signature for each input, or + # running the estimate for each node exir filters. + def input_spec_reads(length): + ep = torch.export.export(Chain(length), (torch.randn(4, 16),)) + input_specs = ep.graph_signature.input_specs + nodes = [node for node in ep.graph.nodes if node.op == "call_function"] + with patch.object( + CapabilityBasedPartitioner, + "propose_partitions", + autospec=True, + side_effect=CapabilityBasedPartitioner.propose_partitions, + ) as partition, patch.object( + ExportGraphSignature, + "input_specs", + new_callable=PropertyMock, + return_value=input_specs, + create=True, + ) as read_input_specs: + _, keep_whole = QnnPartitioner(compiler_specs).ops_to_not_decompose(ep) + for node in nodes: + keep_whole(node) + QnnPartitioner._drop_partitions_without_runtime_inputs( + [Partition(nodes=[node]) for node in nodes], ep + ) + partition.assert_not_called() + return read_input_specs.call_count + + reads = input_spec_reads(2) + self.assertGreater(reads, 0) + self.assertEqual(reads, input_spec_reads(4)) + def test_partitioner_falls_back_on_bfloat16(self): class Bf16Region(torch.nn.Module): def __init__(self): @@ -1259,7 +1837,7 @@ def forward(self, x): ) except RuntimeError as e: if "Failed to initialize QNN backend" in str(e): - self.skipTest(f"QNN SDK not available: {e}") + skip_without_qnn_sdk(self, e) raise cpu_nodes = [ node @@ -1298,7 +1876,7 @@ def forward(self, x, mask): ) except RuntimeError as e: if "Failed to initialize QNN backend" in str(e): - self.skipTest(f"QNN SDK not available: {e}") + skip_without_qnn_sdk(self, e) raise cpu_ops = { node.target @@ -1349,7 +1927,7 @@ def forward(self, x): ) except RuntimeError as e: if "QNN" in str(e) or "qnn" in str(e): - self.skipTest(f"QNN SDK not available: {e}") + skip_without_qnn_sdk(self, e) raise # inspected before to_executorch(), which swaps in out-variant ops @@ -1420,7 +1998,7 @@ def forward(self, x): ) except RuntimeError as e: if "QNN" in str(e) or "qnn" in str(e): - self.skipTest(f"QNN SDK not available: {e}") + skip_without_qnn_sdk(self, e) raise export_spy.assert_not_called() @@ -1449,7 +2027,7 @@ def forward(self, x): ) except RuntimeError as e: if "QNN" in str(e) or "qnn" in str(e): - self.skipTest(f"QNN SDK not available: {e}") + skip_without_qnn_sdk(self, e) raise def test_index_put_int64_value_not_quantized(self): @@ -1737,7 +2315,6 @@ def test_backend_bundle_cache_survives_an_expired_entry(self): one bundle had come and gone every later request built a fresh backend -- even while another bundle for that type was alive. """ - import os import tempfile import executorch.backends.qualcomm.python.PyQnnManagerAdaptor as PyQnnManager @@ -1759,7 +2336,9 @@ def test_backend_bundle_cache_survives_an_expired_entry(self): try: first = PyQnnManager.QnnManager(option) if first.InitBackend().value != 0: - self.skipTest("QNN backend unavailable") + skip_without_qnn_sdk(self, "QNN backend unavailable") + cap.seek(0) + self.fail(cap.read().decode("utf-8", "replace")) first.Destroy() del first diff --git a/backends/qualcomm/tests/test_qnn_delegate.py b/backends/qualcomm/tests/test_qnn_delegate.py index 0c64c690d47..33aec5a5f2e 100644 --- a/backends/qualcomm/tests/test_qnn_delegate.py +++ b/backends/qualcomm/tests/test_qnn_delegate.py @@ -529,10 +529,17 @@ def test_qnn_backend_bmm(self): self.lower_module_and_test_output(module, sample_input) def test_qnn_backend_cast(self): - modules = [Cast(), CastMultiUsers()] # noqa: F405 + modules = [ + Cast(), # noqa: F405 + CastMultiUsers(), # noqa: F405 + CastToFloat(), # noqa: F405 + CastToFloat(torch.float16), # noqa: F405 + ] sample_inputs = [ (10 * torch.rand((9, 4, 5, 3)),), (torch.randint(0, 3, size=(3, 3)), torch.randn(3, 3)), + (torch.arange(256, dtype=torch.uint8).reshape(16, 16),), + (torch.arange(256, dtype=torch.uint8).reshape(16, 16),), ] for i, (module, sample_input) in enumerate(zip(modules, sample_inputs)): with self.subTest(i=i): @@ -3726,10 +3733,18 @@ def test_qnn_backend_bmm(self): self.lower_module_and_test_output(qdq_module, sample_input) def test_qnn_backend_cast(self): - module = CastMultiUsers() # noqa: F405 - sample_input = (torch.randint(0, 3, size=(3, 3)), torch.randn(3, 3)) - module = self.get_qdq_module(module, sample_input) - self.lower_module_and_test_output(module, sample_input) + # With rtol 1, any output between 0 and twice the reference passes, so + # a uint8 cast that gives only 0 or 1 would pass too. + self.rtol = 1e-1 + modules = [CastMultiUsers(), CastToFloat()] # noqa: F405 + sample_inputs = [ + (torch.randint(0, 3, size=(3, 3)), torch.randn(3, 3)), + (torch.arange(256, dtype=torch.uint8).reshape(16, 16),), + ] + for i, (module, sample_input) in enumerate(zip(modules, sample_inputs)): + with self.subTest(i=i): + module = self.get_qdq_module(module, sample_input) + self.lower_module_and_test_output(module, sample_input) def test_qnn_backend_cat(self): modules = [Cat2(), Cat3(), Cat4(), Cat5()] # noqa: F405 @@ -7754,7 +7769,9 @@ def test_qnn_backend_draw_graph(self): ) nodes_to_wrappers = defaultdict(dict) node_visitors = get_node_visitors( - delegated_program.exported_program, enable_tensor_dump=False + delegated_program.exported_program, + enable_tensor_dump=False, + backend_type=QnnExecuTorchBackendType.kHtpBackend, ) py_op_wrapper_list = [] @@ -8916,7 +8933,9 @@ def test_qnn_backend_draw_graph(self): ) nodes_to_wrappers = defaultdict(dict) node_visitors = get_node_visitors( - delegated_program.exported_program, enable_tensor_dump=False + delegated_program.exported_program, + enable_tensor_dump=False, + backend_type=QnnExecuTorchBackendType.kHtpBackend, ) py_op_wrapper_list = [] diff --git a/backends/transforms/targets.bzl b/backends/transforms/targets.bzl index 37aa83a0985..f4456cc2812 100644 --- a/backends/transforms/targets.bzl +++ b/backends/transforms/targets.bzl @@ -631,6 +631,17 @@ def define_common_targets(): ], ) + runtime.python_test( + name = "test_reserve_node_names", + srcs = [ + "test/test_reserve_node_names.py", + ], + deps = [ + "//caffe2:torch", + ":utils", + ], + ) + runtime.python_test( name = "test_convert_dtype_pass", srcs = [ diff --git a/backends/transforms/test/test_reserve_node_names.py b/backends/transforms/test/test_reserve_node_names.py new file mode 100644 index 00000000000..20c7b7a779a --- /dev/null +++ b/backends/transforms/test/test_reserve_node_names.py @@ -0,0 +1,39 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import unittest + +import torch +from executorch.backends.transforms.utils import reserve_node_names + + +class TestReserveNodeNames(unittest.TestCase): + def test_new_node_does_not_take_an_existing_name(self): + class Model(torch.nn.Module): + def forward(self, x, mul): + return x * 2 + mul + + # The input takes the name "mul", so export renames the multiply to + # "mul_1" without recording that name in the graph's namespace. + ep = torch.export.export(Model(), (torch.randn(2), torch.randn(2))) + graph = ep.graph_module.graph + self.assertIn("mul_1", [node.name for node in graph.nodes]) + # A skip, not a failure: a torch update that records the name is fine. + if "mul_1" in graph._graph_namespace._used_names: + self.skipTest( + "export now records the names it renames, so this no longer " + "tests reserve_node_names; see " + "https://github.com/pytorch/pytorch/issues/202927" + ) + + reserve_node_names(ep.graph_module) + (add,) = graph.find_nodes(op="call_function", target=torch.ops.aten.add.Tensor) + with graph.inserting_after(add): + node = graph.create_node( + "call_function", torch.ops.aten.neg.default, (add,), name="mul_1" + ) + self.assertNotEqual(node.name, "mul_1") + graph.lint() diff --git a/exir/backend/partitioner.py b/exir/backend/partitioner.py index c0edf0c71d8..c309b3e07a0 100644 --- a/exir/backend/partitioner.py +++ b/exir/backend/partitioner.py @@ -105,6 +105,19 @@ def ops_to_not_decompose( guaranteed that the program that the backend receives will not have any of these ops decomposed. + to_edge_transform_and_lower calls this method at two stages, possibly more than once + in each, and `ep.dialect` tells them apart. Each returned filter is only applied to + the nodes of the `ep` passed to that same call, including those of the submodules of + its top-level cond, map and scan ops. + + * During decomposition, on the ATen program (`ep.dialect == "ATEN"`). The list and + the filter decide which nodes are kept whole. + * After lowering, on the EDGE program (`ep.dialect == "EDGE"`). A node still in the + graph whose op is in this list, or in the list of an earlier partitioner for the + same method, and that the filter accepts was neither decomposed nor delegated, + and raises an error, except for some quantization ops and, when this call + returns a filter, ops that exir does not preserve. + Returns: List[torch._ops.OpOverload]: a list of operator names that should not be decomposed. Optional[Callable[[torch.fx.Node], bool]]]: an optional callable, acting as a filter, that users can provide From a9cbb59d8b358ecf6f4cf8cddb943c4fda546b23 Mon Sep 17 00:00:00 2001 From: Bryson Miller Date: Wed, 4 Nov 2026 20:56:48 -0500 Subject: [PATCH 073/128] Arm backend: Fix int64 partition test after mixed-dtype cat promotion (#24621) ### Summary `test_int64_extension_partition_tosa_INT[cat_byte]` fails on main since #24306 merged. #24381 now promotes mixed `cat` operands in `to_edge`, so the int8 operand reaches the partitioner as int64 and the `cat` delegates. - `cat_byte`, `cat_narrow` and `cat_bool` now expect what happens after promotion and are named `*_promoted`. - `or_byte` and `or_bool` use `bitwise_or`, which `to_edge` does not promote, so the suite still checks that an int8 operand falls back and that a bool operand beside int64 delegates. - A unit test checks the operand-width rule directly, so these cases don't depend on which ops `to_edge` promotes. - The suite comment notes that `to_edge` widens the operands of some operators first. ### Test Plan `test_int64_extension_partition_tosa_INT` and the new `test_int64_operand_check_widths` pass on main. This PR was authored with Claude Code. cc @zingo @YufengShi-dudu @Erik-Lundell --------- Co-authored-by: Zingo Andersen --- backends/arm/test/misc/test_int64.py | 11 +++++++---- .../arm/test/misc/test_tosa_operator_support.py | 13 +++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/backends/arm/test/misc/test_int64.py b/backends/arm/test/misc/test_int64.py index 6bf6381b1ef..2ff584a8e01 100644 --- a/backends/arm/test/misc/test_int64.py +++ b/backends/arm/test/misc/test_int64.py @@ -139,16 +139,19 @@ def forward(self, cond, x): # A listed operator delegates when every operand casts to int64; an unlisted -# operator or an operand narrower than int32 falls back instead. +# operator or an operand narrower than int32 falls back instead. to_edge widens +# the operands of some operators, such as cat, before partitioning. int64_extension_partition_suite = { "add": (lambda x, y: x + y, (_WIDE, _WIDE_2), 1), "pad": (lambda x: torch.nn.functional.pad(x, (1, 1)), (_WIDE,), 1), "cumsum": (lambda x: torch.cumsum(x, 0), (_WIDE,), 0), - "cat_narrow": (lambda x, y: torch.cat([x, y]), (_WIDE, _NARROW), 1), + "cat_narrow_promoted": (lambda x, y: torch.cat([x, y]), (_WIDE, _NARROW), 1), "or_narrow": (torch.bitwise_or, (_WIDE, _NARROW), 1), - "cat_byte": (lambda x, y: torch.cat([x, y]), (_WIDE, _BYTES), 0), + "cat_byte_promoted": (lambda x, y: torch.cat([x, y]), (_WIDE, _BYTES), 1), + "or_byte": (torch.bitwise_or, (_WIDE, _BYTES), 0), "eq_bool": (lambda x, y: x == y, (_WIDE, _FLAGS), 0), - "cat_bool": (lambda x, y: torch.cat([x, y]), (_FLAGS, _WIDE), 1), + "cat_bool_promoted": (lambda x, y: torch.cat([x, y]), (_FLAGS, _WIDE), 1), + "or_bool": (torch.bitwise_or, (_FLAGS, _WIDE), 1), "where": (torch.where, (_COND, _WIDE, _WIDE_2), 0), "where_buffer": (Int64BufferWhere(), (_COND, _WIDE_2), 0), "strided_slice": (lambda x: x[::2], (torch.arange(5, dtype=torch.int64),), 0), diff --git a/backends/arm/test/misc/test_tosa_operator_support.py b/backends/arm/test/misc/test_tosa_operator_support.py index 25c40d63905..a65ec268355 100644 --- a/backends/arm/test/misc/test_tosa_operator_support.py +++ b/backends/arm/test/misc/test_tosa_operator_support.py @@ -334,3 +334,16 @@ def test_int64_operand_check_skips_symbolic_inputs() -> None: node = graph.call_function(exir_ops.edge.aten.view_copy.default, (x, [size, -1])) assert _int64_operands_agree(node) + + +@pytest.mark.parametrize( + ("dtype", "agree"), + ((torch.int64, True), (torch.int32, True), (torch.bool, True), (torch.int8, False)), +) +def test_int64_operand_check_widths(dtype, agree) -> None: + graph = torch.fx.Graph() + x = _placeholder(graph, "x", (4,), torch.int64) + y = _placeholder(graph, "y", (4,), dtype) + node = graph.call_function(exir_ops.edge.aten.bitwise_or.Tensor, (x, y)) + + assert _int64_operands_agree(node) == agree From af8c355be3c21e38a4a78609fd751ae005b82a9a Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Wed, 4 Nov 2026 18:10:31 -0800 Subject: [PATCH 074/128] MLX: document how index ops treat bad indices, fix --isolate cleanup and pte_inspector exit code (#24631) ## What was wrong - The MLX backend clamps a bad `gather` or `index_put` index on the GPU instead of raising, so it stays inside the tensor. The op support page never said so, and the `gather` docstring missed that a negative index counts from the end. - `run_all_tests --isolate` killed only the test process on a timeout, and nothing if it crashed, so the `op_test_runner` it started could keep running. - `pte_inspector --mlx-instructions` exited with 0 when the file had no MLX delegate or could not be parsed. ## What this change does - The op support page explains the clamp and how other index ops treat a bad index. ```python x = torch.arange(4.0).reshape(1, 4) torch.gather(x, 1, torch.tensor([[9, -1, -7]])) # eager: raises an error # MLX: returns [[3., 3., 0.]] ``` - The `gather` and `index_put` docstrings state the real behavior. - When an isolated test ends, `--isolate` kills its process group. - `pte_inspector --mlx-instructions` exits with 1 when it fails, and the CI step that uses it fails too. ## What was tested The new harness tests ran on an M-series Mac; each fails without its fix. The changed CI step fails locally on a program without an MLX delegate. The MLX partitioner tests, including the new `index_add` and empty-axis cases, passed in the macOS MLX CI job. --- .github/workflows/mlx.yml | 5 +- backends/mlx/ops.py | 9 +- backends/mlx/pte_inspector.py | 12 +- backends/mlx/test/run_all_tests.py | 28 +++-- backends/mlx/test/test_partitioner.py | 27 ++++- backends/mlx/test/test_run_all_tests.py | 123 ++++++++++++++++++++- docs/source/backends/mlx/mlx-op-support.md | 31 ++++++ 7 files changed, 209 insertions(+), 26 deletions(-) diff --git a/.github/workflows/mlx.yml b/.github/workflows/mlx.yml index 203c1283c63..fa586b84831 100644 --- a/.github/workflows/mlx.yml +++ b/.github/workflows/mlx.yml @@ -275,8 +275,9 @@ jobs: echo "::endgroup::" echo "::group::Check AsType node count" - ASTYPE_COUNT=$(${CONDA_RUN} python -m executorch.backends.mlx.pte_inspector \ - /tmp/qwen35_moe_mlx_tiny/model.pte --mlx-instructions 2>&1 | grep -c "AsTypeNode" || true) + ${CONDA_RUN} python -m executorch.backends.mlx.pte_inspector \ + /tmp/qwen35_moe_mlx_tiny/model.pte --mlx-instructions > /tmp/qwen35_moe_mlx_tiny/instructions.txt + ASTYPE_COUNT=$(grep -c "AsTypeNode" /tmp/qwen35_moe_mlx_tiny/instructions.txt || true) echo "AsType nodes: ${ASTYPE_COUNT}" if [ "$ASTYPE_COUNT" -gt 23 ]; then echo "Failed: expected no more than 23 AsType nodes, got ${ASTYPE_COUNT}" diff --git a/backends/mlx/ops.py b/backends/mlx/ops.py index c73331431db..2788216fc95 100644 --- a/backends/mlx/ops.py +++ b/backends/mlx/ops.py @@ -2611,8 +2611,10 @@ def _gather_handler(P: MLXProgramBuilder, n: Node) -> Slot: other axes. aten.gather instead reads only the first index.size(d) entries of x there, so x is sliced to the index shape first where the two differ. - Unlike eager, an out-of-range index does not raise: the runtime clamps it - to the first or last element so the read stays inside x. + Unlike eager, a bad index does not raise. Eager rejects any index outside + [0, size - 1]; the runtime clamps it to [-size, size - 1] instead, and a + negative index counts from the end, so the read stays inside x. A + non-empty gather from an empty axis still raises. """ args = P.args(n) require_args(args, 3, 3, "aten.gather") @@ -2742,7 +2744,8 @@ def _index_put_handler(P: MLXProgramBuilder, n: Node) -> Slot: for each indexed axis. Unlike eager, an out-of-range index does not raise: the runtime clamps it - to the first or last slice so the write stays inside self. + to the first or last slice so the write stays inside self. If an indexed + axis is empty, the runtime returns self unchanged. """ args = P.args(n) require_args(args, 3, 4, "aten.index_put") diff --git a/backends/mlx/pte_inspector.py b/backends/mlx/pte_inspector.py index 08a048a43f6..bf914844aef 100644 --- a/backends/mlx/pte_inspector.py +++ b/backends/mlx/pte_inspector.py @@ -623,12 +623,12 @@ def show_mlx_summary(pte_data: bytes) -> None: # noqa: C901 traceback.print_exc() -def show_mlx_instructions(pte_data: bytes) -> None: # noqa: C901 +def show_mlx_instructions(pte_data: bytes) -> bool: # noqa: C901 try: mlx_delegates = _find_mlx_delegates(pte_data) if not mlx_delegates: print("No MLX delegates found in this PTE file.", file=sys.stderr) - return + return False if len(mlx_delegates) > 1: print( @@ -636,11 +636,13 @@ def show_mlx_instructions(pte_data: bytes) -> None: # noqa: C901 file=sys.stderr, ) + ok = True for idx, (delegate_idx, _delegate) in enumerate(mlx_delegates): try: mlx = _load_mlx_payload(pte_data, delegate_index=idx) except ValueError as e: print(f"\nError: {e}", file=sys.stderr) + ok = False continue graph = parse_mlx_flatbuffer(mlx.fb_data) @@ -649,6 +651,7 @@ def show_mlx_instructions(pte_data: bytes) -> None: # noqa: C901 f"\nError parsing delegate {idx}: {graph['error']}", file=sys.stderr, ) + ok = False continue # Print delegate header @@ -747,6 +750,8 @@ def show_mlx_instructions(pte_data: bytes) -> None: # noqa: C901 except Exception as e: print(f"Error showing MLX instructions: {e}", file=sys.stderr) traceback.print_exc() + return False + return ok def main(): # noqa: C901 @@ -829,7 +834,8 @@ def main(): # noqa: C901 print(f"Loaded {len(pte_data)} bytes from {args.pte_file}", file=sys.stderr) if args.mlx_instructions: - show_mlx_instructions(pte_data) + if not show_mlx_instructions(pte_data): + sys.exit(1) return if args.mlx_summary: diff --git a/backends/mlx/test/run_all_tests.py b/backends/mlx/test/run_all_tests.py index 13c165013f8..144e5e1a8bb 100644 --- a/backends/mlx/test/run_all_tests.py +++ b/backends/mlx/test/run_all_tests.py @@ -34,6 +34,8 @@ import argparse import importlib import multiprocessing +import os +import signal import subprocess import sys from multiprocessing import Pool @@ -188,18 +190,26 @@ def _run_test_in_subprocess( cmd.append("--verbose") try: - result = subprocess.run( - cmd, - timeout=timeout, - capture_output=False, - ) - return result.returncode == 0 - except subprocess.TimeoutExpired: - print(f"✗ FAILED: {config_name} - Timeout after {timeout}s") - return False + # A session of its own, so a timeout also kills the op_test_runner the + # child started, not just the child. + proc = subprocess.Popen(cmd, start_new_session=True) except Exception as e: print(f"✗ FAILED: {config_name} - Subprocess error: {e}") return False + try: + return proc.wait(timeout=timeout) == 0 + except subprocess.TimeoutExpired: + print(f"✗ FAILED: {config_name} - Timeout after {timeout}s") + return False + finally: + # Also when the child has exited: if it crashed, its op_test_runner can + # still be running. ProcessLookupError means the group is gone; macOS + # raises PermissionError when only the child's zombie is left. + try: + os.killpg(proc.pid, signal.SIGKILL) + except (ProcessLookupError, PermissionError): + pass + proc.wait() def run_tests_parallel( diff --git a/backends/mlx/test/test_partitioner.py b/backends/mlx/test/test_partitioner.py index a8bc24d6489..baee16f155f 100644 --- a/backends/mlx/test/test_partitioner.py +++ b/backends/mlx/test/test_partitioner.py @@ -275,12 +275,27 @@ def forward(self, x, index): x = torch.arange(12.0).reshape(3, 4) program = _lower(Gather(), (x, torch.zeros(3, 2, dtype=torch.long))) method = Runtime.get().load_program(program.buffer).load_method("forward") - # Eager raises; the delegate clamps to the nearest end instead of - # reading outside x. - out = method.execute([x, torch.tensor([[9, 0], [-7, 1], [3, 2]])])[0] - clamped = torch.tensor([[3, 0], [0, 1], [3, 2]]) + # Eager raises on 9, -1 and -7. The delegate counts -1 from the end and + # clamps 9 and -7 to the nearest end instead of reading outside x. + out = method.execute([x, torch.tensor([[9, -1], [-7, 1], [3, 2]])])[0] + clamped = torch.tensor([[3, 3], [0, 1], [3, 2]]) self.assertTrue(torch.equal(out, torch.gather(x, 1, clamped))) + def test_index_add_clamps_out_of_range_indices(self): + class IndexAdd(nn.Module): + def forward(self, x, index, values): + return x.index_add(0, index, values) + + x, values = torch.randn(8, 4), torch.randn(3, 4) + program = _lower(IndexAdd(), (x, torch.tensor([0, 1, 2]), values)) + self.assertEqual(_cpu_ops(program), set()) + method = Runtime.get().load_program(program.buffer).load_method("forward") + # Eager raises on 9, -1 and -9. The delegate counts -1 from the end, + # clamps 9 and -9 to the nearest end, and adds both rows aimed at row 7. + out = method.execute([x, torch.tensor([9, -1, -9]), values])[0] + expected = IndexAdd()(x, torch.tensor([7, 7, 0]), values) + self.assertTrue(torch.allclose(out, expected)) + def test_empty_axis(self): # The delegate cannot return an empty tensor yet, so both models sum. class IndexPut(nn.Module): @@ -303,6 +318,10 @@ def run(model, *inputs): self.assertTrue(torch.equal(out, torch.zeros(4))) out = run(Gather(), torch.zeros(3, 0), torch.zeros(3, 0, dtype=torch.long)) self.assertTrue(torch.equal(out, torch.zeros(3))) + # Eager raises on a non-empty write, but there is nowhere to write, + # so the delegate returns the input. + out = run(IndexPut(), empty, torch.tensor([0]), torch.ones(1, 4)) + self.assertTrue(torch.equal(out, torch.zeros(4))) # Eager raises; with no element to clamp to, the delegate must too. with self.assertRaisesRegex(RuntimeError, "empty axis"): run(Gather(), torch.zeros(3, 0), torch.zeros(3, 2, dtype=torch.long)) diff --git a/backends/mlx/test/test_run_all_tests.py b/backends/mlx/test/test_run_all_tests.py index ad4366a0975..7d0787bfd0a 100644 --- a/backends/mlx/test/test_run_all_tests.py +++ b/backends/mlx/test/test_run_all_tests.py @@ -6,11 +6,18 @@ """Tests for the MLX op test harness itself.""" +import os +import signal +import subprocess +import sys import tempfile +import time import unittest from pathlib import Path +from unittest import mock import torch +from executorch.backends.mlx import pte_inspector from executorch.backends.mlx.test.run_all_tests import _run_test_in_subprocess from executorch.backends.mlx.test.test_utils import ( clean_test_outputs, @@ -25,17 +32,123 @@ def test_isolated_run_reports_result(self): self.assertTrue(_run_test_in_subprocess("sub_10")) self.assertFalse(_run_test_in_subprocess("no_such_config")) + def test_isolated_run_timeout_kills_grandchildren(self): + start = time.monotonic() + pid = self._run_child_with_grandchild("wait", timeout=10) + # Waiting out the sleep would mean the kill missed it. + self.assertLess(time.monotonic() - start, 30) + self._assert_exits(pid) + + def test_isolated_run_kills_grandchildren_of_a_crashed_child(self): + self._assert_exits(self._run_child_with_grandchild("exit 1")) + + def _run_child_with_grandchild(self, ending: str, timeout: int = 60) -> int: + with tempfile.TemporaryDirectory() as tmp: + # Stands in for the test process: it starts a long-running child, + # like op_test_runner, then waits for it or exits without it. + child = Path(tmp) / "child.sh" + pid_file = Path(tmp) / "grandchild.pid" + child.write_text(f"#!/bin/sh\nsleep 60 &\necho $! > {pid_file}\n{ending}\n") + child.chmod(0o755) + with mock.patch.object(sys, "executable", str(child)): + self.assertFalse(_run_test_in_subprocess("sub_10", timeout=timeout)) + pid = int(pid_file.read_text()) + self.addCleanup(_kill_if_alive, pid) + return pid + + def _assert_exits(self, pid: int) -> None: + deadline = time.monotonic() + 10 + while _is_alive(pid) and time.monotonic() < deadline: + time.sleep(0.1) + self.assertFalse(_is_alive(pid)) + + def test_isolated_run_survives_child_exiting_before_kill(self): + # The child exits between the timeout and the kill, so the kill finds + # only its zombie, which macOS reports as PermissionError. + class ExitsBeforeKill(subprocess.Popen): + def wait(self, timeout=None): + if timeout is None: + return super().wait() + deadline = time.monotonic() + 10 + while not _is_zombie(self.pid) and time.monotonic() < deadline: + time.sleep(0.05) + raise subprocess.TimeoutExpired(self.args, timeout) + + with mock.patch.object(sys, "executable", "/usr/bin/true"), mock.patch.object( + subprocess, "Popen", ExitsBeforeKill + ): + self.assertFalse(_run_test_in_subprocess("sub_10")) + def test_summary_of_program_without_mlx_delegate_returns(self): - program = to_edge( - torch.export.export(torch.nn.ReLU(), (torch.randn(4),)) - ).to_executorch() with tempfile.TemporaryDirectory() as tmp: - path = Path(tmp) / "model.pte" - path.write_bytes(program.buffer) + path = _write_program_without_mlx_delegate(Path(tmp)) # A test that lowers to no MLX delegate fails on its delegate count # check after this, so printing must not end the process. print_mlx_graph_summary(path) + def test_inspector_cli_fails_without_mlx_delegate(self): + with tempfile.TemporaryDirectory() as tmp: + path = _write_program_without_mlx_delegate(Path(tmp)) + result = subprocess.run( + [ + sys.executable, + "-m", + "executorch.backends.mlx.pte_inspector", + str(path), + "--mlx-instructions", + ], + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 1) + self.assertIn("No MLX delegates", result.stderr) + + def test_inspector_fails_on_unreadable_input(self): + self.assertIs(pte_inspector.show_mlx_instructions(b"not a program"), False) + unreadable_delegate = { + "load": {"_load_mlx_payload": mock.Mock(side_effect=ValueError("bad"))}, + "parse": { + "_load_mlx_payload": mock.Mock(), + "parse_mlx_flatbuffer": mock.Mock(return_value={"error": "bad"}), + }, + } + for name, patches in unreadable_delegate.items(): + with self.subTest(name), mock.patch.multiple( + pte_inspector, + _find_mlx_delegates=mock.Mock(return_value=[(0, {})]), + **patches, + ): + self.assertIs(pte_inspector.show_mlx_instructions(b""), False) + + +def _write_program_without_mlx_delegate(directory: Path) -> Path: + program = to_edge( + torch.export.export(torch.nn.ReLU(), (torch.randn(4),)) + ).to_executorch() + path = directory / "model.pte" + path.write_bytes(program.buffer) + return path + + +def _is_alive(pid: int) -> bool: + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + return True + + +def _is_zombie(pid: int) -> bool: + state = subprocess.run( + ["ps", "-o", "stat=", "-p", str(pid)], capture_output=True, text=True + ).stdout + return state.startswith("Z") + + +def _kill_if_alive(pid: int) -> None: + if _is_alive(pid): + os.kill(pid, signal.SIGKILL) + if __name__ == "__main__": unittest.main() diff --git a/docs/source/backends/mlx/mlx-op-support.md b/docs/source/backends/mlx/mlx-op-support.md index 4f9acd576c3..fa1fe64681c 100644 --- a/docs/source/backends/mlx/mlx-op-support.md +++ b/docs/source/backends/mlx/mlx-op-support.md @@ -8,3 +8,34 @@ For the current list of supported operators and fused patterns, see the source: - **[patterns.py](https://github.com/pytorch/executorch/blob/main/backends/mlx/patterns.py)** — Multi-node fused patterns (quantized linear, SDPA, KV cache, etc.) During lowering, the MLX partitioner prints a summary of supported and unsupported ops so you can see which ones are delegated and which fall back to CPU. + +## Out-of-Range Indices in `gather` and `index_put` + +Eager PyTorch raises an error when `gather` or `index_put` gets an index outside the tensor. The MLX backend does not, because a check would have to wait for the GPU in the middle of the graph. Instead, it clamps each index to `[-size, size - 1]` on the GPU, where `size` is the length of the indexed axis: + +- A negative index counts from the end, so `-1` is the last position. Eager `index_put` does the same, but many other eager index ops, such as `gather`, `index_add_`, `index_copy_` and `embedding`, reject negative indices. On MLX, the other index ops below also count from the end. +- An index past either end reads or writes the first or last position. + +For example: + +```python +x = torch.arange(4.0).reshape(1, 4) +torch.gather(x, 1, torch.tensor([[9, -1, -7]])) +# eager: raises an error +# MLX: reads positions 3, 3 and 0, so returns [[3., 3., 0.]] +``` + +So a bad index never reads or writes memory outside the tensor, but it is still a bug in the model: + +- `gather` returns values from the wrong place. +- `index_put` overwrites real data at the first or last position, or adds to it with `accumulate=True`. For example, if an 8-slot cache is written as `cache[:, pos] = v` with `pos = torch.tensor([8])`, the write goes to slot 7. +- `index_add_` lowers to `index_put` with `accumulate=True`, so it treats a bad index the same way, but adds instead of overwriting. + +Other ops that take indices behave differently, for example: + +- `index_copy_` into a mutable buffer, and reading `x[i]` with a Python int, raise an error on an index outside `[-size, size - 1]`. +- `embedding`, `index_select`, `scatter_add`, and reading `x[idx]` with an index tensor do not check or clamp. An index outside `[-size, size - 1]` there can read or write memory outside the tensor. + +To find bad indices, run the model in eager PyTorch on the CPU first, where they raise. Keep run-time indices in range too, for example by stopping generation before a cache position reaches the cache size. + +On an empty axis there is nothing to clamp to: `gather` with a non-empty index raises, and `index_put` returns the input unchanged. From ff062695174afeea8cb47d0bced6e58d74efc032 Mon Sep 17 00:00:00 2001 From: Digant Desai Date: Wed, 4 Nov 2026 22:33:29 -0600 Subject: [PATCH 075/128] Skip URL lint for the login-gated NXP MCUXpresso SDK link (#24642) ### Summary `nightly / link-check / lint-urls` fails on the MCUXpresso SDK link in `docs/source/backends-cadence.md` and `docs/source/archive/backends-cadence-legacy.md`. `https://mcuxpresso.nxp.com/en/select?device=EVK-MIMXRT685` now redirects to `?hardware=EVK-MIMXRT685` and then to an NXP sign-in page, which returns 404 to both of the checker's curl probes. The link still works for a signed-in user, so this points both docs at the `?hardware=` URL and marks the lines `@lint-ignore`. ### Test plan Ran the `lint_urls.sh` URL pattern over both files: the NXP SDK Builder URL is no longer extracted, and the other links on those pages are unchanged. This PR was authored with Metamate (Claude). --- docs/source/archive/backends-cadence-legacy.md | 2 +- docs/source/backends-cadence.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/archive/backends-cadence-legacy.md b/docs/source/archive/backends-cadence-legacy.md index 60817c83342..67a78ffb8b7 100644 --- a/docs/source/archive/backends-cadence-legacy.md +++ b/docs/source/archive/backends-cadence-legacy.md @@ -51,7 +51,7 @@ In order to be able to succesfully build and run ExecuTorch on a Xtensa HiFi4 DS - [J-Link](https://www.segger.com/downloads/jlink/) - Needed to flash the board with the firmware images. You can install this on the same platform that you installed the MCUXpresso IDE on. - Note: depending on the version of the NXP board, another probe than JLink might be installed. In any case, flashing is done using the MCUXpresso IDE in a similar way. - - [MCUXpresso SDK](https://mcuxpresso.nxp.com/en/select?device=EVK-MIMXRT685) + - [MCUXpresso SDK](https://mcuxpresso.nxp.com/en/select?hardware=EVK-MIMXRT685) - Download this SDK to your Linux machine, extract it and take a note of the path where you store it. You'll need this later. - [Xtensa compiler](https://tensilicatools.com/platform/i-mx-rt600/) - Download this to your Linux machine. This is needed to build ExecuTorch for the HiFi4 DSP. diff --git a/docs/source/backends-cadence.md b/docs/source/backends-cadence.md index ddd258cdca5..18130e84bdb 100644 --- a/docs/source/backends-cadence.md +++ b/docs/source/backends-cadence.md @@ -46,7 +46,7 @@ In order to be able to succesfully build and run ExecuTorch on a Xtensa HiFi4 DS - [J-Link](https://www.segger.com/downloads/jlink/) - Needed to flash the board with the firmware images. You can install this on the same platform that you installed the MCUXpresso IDE on. - Note: depending on the version of the NXP board, another probe than JLink might be installed. In any case, flashing is done using the MCUXpresso IDE in a similar way. - - [MCUXpresso SDK](https://mcuxpresso.nxp.com/en/select?device=EVK-MIMXRT685) + - [MCUXpresso SDK](https://mcuxpresso.nxp.com/en/select?hardware=EVK-MIMXRT685) - Download this SDK to your Linux machine, extract it and take a note of the path where you store it. You'll need this later. - [Xtensa compiler](https://tensilicatools.com/platform/i-mx-rt600/) - Download this to your Linux machine. This is needed to build ExecuTorch for the HiFi4 DSP. From 4a4a7530c228e4e0f3fcfbe7b03de9fabad85fab Mon Sep 17 00:00:00 2001 From: Jacob Stevens Date: Wed, 4 Nov 2026 23:48:22 -0500 Subject: [PATCH 076/128] Validate ATen-mode Method inputs against the exported dim order (#24609) Summary: _____ ### Context All 18 cohort tests fail in `Method::set_input` with "Input N has strides that do not match the method's expected layout". That covers the ATen pybindings `test_*channels_last*` cases and the novadepth replayer/depthlink tests, where `MldModelExecutorchBackend::registerMethodInputs` aborts on `pose_input`. D129404404 added `validate_input_tensor_layout` and is the confirmed source of this breakage. The check itself is intended, but its ATen-mode behavior is an unintended regression. In ATen builds, `resize_tensor_impl` calls `set_sizes_contiguous`, which resets the input's exported dim-order strides to contiguous. The check then rejects sources that match the method's exported layout exactly, such as channels-last tensors, or `pose_input` built from `method_meta` dim order. ### Fix **Fix code.** The callers pass the layout the method was exported with, so the runtime check is what's wrong. `validate_input_tensor_layout` now takes the input's `TensorInfo::dim_order()`. In ATen mode it compares the source against strides derived from that dim order instead of the post-resize contiguous strides. Portable mode is unchanged, and mismatched explicit layouts are still rejected. The xplat mirror got the same change. Differential Revision: D129567823 Pull Request resolved: https://github.com/pytorch/executorch/pull/24609 --- extension/pybindings/test/test_pybindings.py | 18 +- runtime/executor/method.cpp | 33 ++- runtime/executor/test/CMakeLists.txt | 12 + .../test/method_input_layout_test.cpp | 246 ++++++++++++++++++ runtime/executor/test/targets.bzl | 18 ++ 5 files changed, 322 insertions(+), 5 deletions(-) create mode 100644 runtime/executor/test/method_input_layout_test.cpp diff --git a/extension/pybindings/test/test_pybindings.py b/extension/pybindings/test/test_pybindings.py index 74d719be0a7..ea75811b113 100644 --- a/extension/pybindings/test/test_pybindings.py +++ b/extension/pybindings/test/test_pybindings.py @@ -2098,10 +2098,22 @@ def test_channels_last(self) -> None: expected = model(inputs[0]) self.assertTrue(torch.allclose(expected, executorch_output)) - def test_torch_input_layout_matches_exported_dim_order(self): - if self.kernel_mode != "portable": - self.skipTest("ATen nonstandard input layouts are deferred to a follow-up") + def test_channels_last_input_with_distinct_values(self) -> None: + # The default inputs are all ones, which every layout reads the same way. + for model in (ModuleChannelsLast(), ModuleChannelsLastInDefaultOut()): + with self.subTest(model=type(model).__name__): + exported_program, inputs = create_program(model) + input_tensor = ( + torch.arange(inputs[0].numel(), dtype=torch.float32) + .reshape(inputs[0].shape) + .to(memory_format=torch.channels_last) + ) + executorch_module = self.load_fn(exported_program.buffer) + torch.testing.assert_close( + executorch_module(input_tensor)[0], model(input_tensor) + ) + def test_torch_input_layout_matches_exported_dim_order(self): class AddInput(ModuleAddSingleInput): def __init__(self, input_tensor): super().__init__() diff --git a/runtime/executor/method.cpp b/runtime/executor/method.cpp index 47238554b71..e512081d6ff 100644 --- a/runtime/executor/method.cpp +++ b/runtime/executor/method.cpp @@ -766,6 +766,8 @@ namespace { ET_NODISCARD Error validate_input_tensor_layout( const executorch::aten::Tensor& t_dst, const executorch::aten::Tensor& t_src, + [[maybe_unused]] Span + expected_dim_order, size_t input_idx) { const auto dim = t_src.dim(); if (dim == 0 || t_src.numel() == 0) { @@ -802,7 +804,27 @@ ET_NODISCARD Error validate_input_tensor_layout( } #endif - const auto dst_strides = t_dst.strides(); + auto dst_strides = t_dst.strides(); +#ifdef USE_ATEN_LIB + std::array + expected_strides{}; + ET_CHECK_OR_RETURN_ERROR( + static_cast(dim) <= kTensorDimensionLimit && + expected_dim_order.size() == static_cast(dim), + InvalidArgument, + "Input %" ET_PRIsize_t " has unsupported rank %zd.", + input_idx, + dim); + ET_CHECK_OK_OR_RETURN_ERROR( + dim_order_to_stride( + t_src.sizes().data(), + expected_dim_order.data(), + dim, + expected_strides.data()), + "Failed to compute expected strides for input %" ET_PRIsize_t, + input_idx); + dst_strides = {expected_strides.data(), static_cast(dim)}; +#endif bool matches = true; bool src_is_contiguous = true; int64_t contiguous_stride = 1; @@ -1464,9 +1486,16 @@ Method::set_input(const EValue& input_evalue, size_t input_idx) { "Error resizing tensor at input %" ET_PRIsize_t, input_idx); ET_CHECK_OK_OR_RETURN_ERROR( - validate_input_tensor_layout(t_dst, t_src, input_idx), + validate_input_tensor_layout( + t_dst, t_src, tensor_meta->dim_order(), input_idx), "Error validating tensor layout at input %" ET_PRIsize_t, input_idx); +#ifdef USE_ATEN_LIB + // resize_tensor() reset t_dst to contiguous strides, but its data is about + // to be t_src's bytes, which kernels must read with t_src's strides. + t_dst.unsafeGetTensorImpl()->set_sizes_and_strides( + t_src.sizes(), t_src.strides()); +#endif if (tensor_meta->is_memory_planned()) { ET_CHECK_OK_OR_RETURN_ERROR( internal::copy_tensor_data(t_dst, t_src), diff --git a/runtime/executor/test/CMakeLists.txt b/runtime/executor/test/CMakeLists.txt index 80dd21498ac..113884e980a 100644 --- a/runtime/executor/test/CMakeLists.txt +++ b/runtime/executor/test/CMakeLists.txt @@ -160,6 +160,18 @@ et_cxx_test( add_dependencies(method_test generated_pte_files) set_property(TEST method_test PROPERTY ENVIRONMENT ${test_env}) +et_cxx_test( + method_input_layout_test + SOURCES + method_input_layout_test.cpp + EXTRA_LIBS + portable_ops_lib + portable_kernels + extension_data_loader +) +add_dependencies(method_input_layout_test generated_pte_files) +set_property(TEST method_input_layout_test PROPERTY ENVIRONMENT ${test_env}) + et_cxx_test( method_meta_test SOURCES method_meta_test.cpp EXTRA_LIBS extension_data_loader program_schema diff --git a/runtime/executor/test/method_input_layout_test.cpp b/runtime/executor/test/method_input_layout_test.cpp new file mode 100644 index 00000000000..a8c70bf720c --- /dev/null +++ b/runtime/executor/test/method_input_layout_test.cpp @@ -0,0 +1,246 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +using executorch::aten::ScalarType; +using executorch::aten::Tensor; +using executorch::ET_RUNTIME_NAMESPACE::Method; +using executorch::ET_RUNTIME_NAMESPACE::Program; +using executorch::runtime::Error; +using executorch::runtime::EValue; +using executorch::runtime::Result; +using executorch::runtime::testing::ManagedMemoryManager; +using executorch::runtime::testing::TensorFactory; +using torch::executor::util::FileDataLoader; + +namespace { + +constexpr size_t kDefaultNonConstMemBytes = 32 * 1024U; +constexpr size_t kDefaultRuntimeMemBytes = 32 * 1024U; + +// The ModuleChannelsLast input is (N, C, H, W) = (1, 3, 4, 5) in channels last. +const std::vector kSizes = {1, 3, 4, 5}; +const std::vector kChannelsLast = {0, 2, 3, 1}; + +// Every element is distinct, unlike an all-ones input, so data read through the +// wrong layout cannot produce the expected output. +std::vector Iota(size_t numel, float start = 0) { + std::vector values(numel); + for (size_t i = 0; i < numel; ++i) { + values[i] = start + static_cast(i); + } + return values; +} + +std::vector Doubled(const std::vector& values) { + std::vector doubled; + for (float value : values) { + doubled.push_back(2 * value); + } + return doubled; +} + +// Moves `logical` (row-major order) into the memory order of `dim_order`. +std::vector ToMemoryOrder( + const std::vector& logical, + const std::vector& sizes, + const std::vector& dim_order) { + const size_t rank = sizes.size(); + std::vector strides(rank); + strides[dim_order[rank - 1]] = 1; + for (int64_t i = static_cast(rank) - 2; i >= 0; --i) { + strides[dim_order[i]] = strides[dim_order[i + 1]] * sizes[dim_order[i + 1]]; + } + std::vector memory(logical.size()); + std::vector index(rank, 0); + for (size_t linear = 0; linear < logical.size(); ++linear) { + int64_t offset = 0; + for (size_t d = 0; d < rank; ++d) { + offset += index[d] * strides[d]; + } + memory[offset] = logical[linear]; + for (int64_t d = static_cast(rank) - 1; d >= 0; --d) { + if (++index[d] < sizes[d]) { + break; + } + index[d] = 0; + } + } + return memory; +} + +// Reads the elements of `t` in row-major order, following its strides. +std::vector LogicalValues(const Tensor& t) { + const int64_t rank = static_cast(t.dim()); + const float* data = t.const_data_ptr(); + std::vector values; + std::vector index(rank, 0); + for (int64_t linear = 0; linear < static_cast(t.numel()); ++linear) { + int64_t offset = 0; + for (int64_t d = 0; d < rank; ++d) { + offset += index[d] * t.strides()[d]; + } + values.push_back(data[offset]); + for (int64_t d = rank - 1; d >= 0; --d) { + if (++index[d] < t.size(d)) { + break; + } + index[d] = 0; + } + } + return values; +} + +} // namespace + +class MethodInputLayoutTest : public ::testing::Test { + protected: + void load_program(const char* path, const char* module_name) { + Result loader = FileDataLoader::from(path); + ASSERT_EQ(loader.error(), Error::Ok); + loaders_.insert( + {module_name, + std::make_unique(std::move(loader.get()))}); + + Result program = Program::load( + loaders_[module_name].get(), + Program::Verification::InternalConsistency); + ASSERT_EQ(program.error(), Error::Ok); + programs_.insert( + {module_name, std::make_unique(std::move(program.get()))}); + } + + void SetUp() override { + executorch::runtime::runtime_init(); + + load_program(std::getenv("ET_MODULE_ADD_PATH"), "add"); + load_program(std::getenv("ET_MODULE_CHANNELS_LAST_PATH"), "channels_last"); + } + + Tensor channels_last_input(const std::vector& logical) { + return tf_.make_with_dimorder( + kSizes, ToMemoryOrder(logical, kSizes, kChannelsLast), kChannelsLast); + } + + // Keeps the tensors it makes alive. + TensorFactory tf_; + + private: + // Must outlive the programs, but tests shouldn't need to touch it. + std::unordered_map> loaders_; + + protected: + std::unordered_map> programs_; +}; + +TEST_F(MethodInputLayoutTest, ChannelsLastInputKeepsItsValues) { + ManagedMemoryManager mmm(kDefaultNonConstMemBytes, kDefaultRuntimeMemBytes); + Result method = + programs_["channels_last"]->load_method("forward", &mmm.get()); + ASSERT_EQ(method.error(), Error::Ok); + + const std::vector logical = Iota(60); + // The test only discriminates if the layouts really differ. + ASSERT_NE(ToMemoryOrder(logical, kSizes, kChannelsLast), logical); + + ASSERT_EQ( + method->set_input(EValue(channels_last_input(logical)), 0), Error::Ok); + ASSERT_EQ(method->execute(), Error::Ok); + + EXPECT_EQ(LogicalValues(method->get_output(0).toTensor()), Doubled(logical)); +} + +TEST_F(MethodInputLayoutTest, ChannelsLastInputCanBeReplaced) { + ManagedMemoryManager mmm(kDefaultNonConstMemBytes, kDefaultRuntimeMemBytes); + Result method = + programs_["channels_last"]->load_method("forward", &mmm.get()); + ASSERT_EQ(method.error(), Error::Ok); + + for (float start : {0.f, 100.f, -30.f}) { + const std::vector logical = Iota(60, start); + ASSERT_EQ( + method->set_input(EValue(channels_last_input(logical)), 0), Error::Ok); + ASSERT_EQ(method->execute(), Error::Ok); + EXPECT_EQ( + LogicalValues(method->get_output(0).toTensor()), Doubled(logical)); + } +} + +TEST_F(MethodInputLayoutTest, DefaultContiguousInputIsAcceptedForChannelsLast) { + ManagedMemoryManager mmm(kDefaultNonConstMemBytes, kDefaultRuntimeMemBytes); + Result method = + programs_["channels_last"]->load_method("forward", &mmm.get()); + ASSERT_EQ(method.error(), Error::Ok); + + const std::vector logical = Iota(60); + ASSERT_EQ(method->set_input(EValue(tf_.make(kSizes, logical)), 0), Error::Ok); + ASSERT_EQ(method->execute(), Error::Ok); + +#ifdef USE_ATEN_LIB + // An ATen tensor reports the strides its data really has, so it is read as + // given. Portable mode instead treats the data as already in the method's + // layout. + EXPECT_EQ(LogicalValues(method->get_output(0).toTensor()), Doubled(logical)); +#endif +} + +TEST_F(MethodInputLayoutTest, MismatchedExplicitLayoutIsRejected) { + ManagedMemoryManager mmm(kDefaultNonConstMemBytes, kDefaultRuntimeMemBytes); + Result method = + programs_["channels_last"]->load_method("forward", &mmm.get()); + ASSERT_EQ(method.error(), Error::Ok); + + const std::vector logical = Iota(60); + const std::vector swapped_hw = {0, 1, 3, 2}; + Tensor input = tf_.make_with_dimorder( + kSizes, ToMemoryOrder(logical, kSizes, swapped_hw), swapped_hw); + EXPECT_EQ(method->set_input(EValue(input), 0), Error::InvalidArgument); + + // A rejected input must not leave the method unable to run a valid one. + ASSERT_EQ( + method->set_input(EValue(channels_last_input(logical)), 0), Error::Ok); + ASSERT_EQ(method->execute(), Error::Ok); + EXPECT_EQ(LogicalValues(method->get_output(0).toTensor()), Doubled(logical)); +} + +TEST_F(MethodInputLayoutTest, ContiguousMethodRejectsTransposedInput) { + ManagedMemoryManager mmm(kDefaultNonConstMemBytes, kDefaultRuntimeMemBytes); + Result method = programs_["add"]->load_method("forward", &mmm.get()); + ASSERT_EQ(method.error(), Error::Ok); + + const std::vector sizes = {2, 2}; + const std::vector x = {1, 2, 3, 4}; + const std::vector y = {10, 20, 30, 40}; + + Tensor transposed = tf_.make_with_dimorder( + sizes, ToMemoryOrder(x, sizes, {1, 0}), /*dim_order=*/{1, 0}); + EXPECT_EQ(method->set_input(EValue(transposed), 0), Error::InvalidArgument); + + // A rejected input must not leave the method unable to run valid ones. + ASSERT_EQ(method->set_input(EValue(tf_.make(sizes, x)), 0), Error::Ok); + ASSERT_EQ(method->set_input(EValue(tf_.make(sizes, y)), 1), Error::Ok); + ASSERT_EQ(method->set_input(EValue(1.0), 2), Error::Ok); + ASSERT_EQ(method->execute(), Error::Ok); + EXPECT_EQ( + LogicalValues(method->get_output(0).toTensor()), + (std::vector{11, 22, 33, 44})); +} diff --git a/runtime/executor/test/targets.bzl b/runtime/executor/test/targets.bzl index 7cebe89ed94..bdbf8fef064 100644 --- a/runtime/executor/test/targets.bzl +++ b/runtime/executor/test/targets.bzl @@ -180,6 +180,24 @@ def define_common_targets(is_fbcode = False): env = modules_env, ) + for aten_mode in get_aten_mode_options(): + aten_suffix = ("_aten" if aten_mode else "") + + runtime.cxx_test( + name = "method_input_layout_test" + aten_suffix, + srcs = [ + "method_input_layout_test.cpp", + ], + deps = [ + ":managed_memory_manager", + "//executorch/extension/data_loader:file_data_loader", + "//executorch/kernels/portable:generated_lib" + aten_suffix, + "//executorch/runtime/core/exec_aten/testing_util:tensor_util" + aten_suffix, + "//executorch/runtime/executor:program" + aten_suffix, + ], + env = modules_env, + ) + runtime.cxx_test( name = "method_meta_test", srcs = [ From 83db5de0bc8cd01ee61e8568fc2beb685b5cc695 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Wed, 4 Nov 2026 21:33:59 -0800 Subject: [PATCH 077/128] CUDA backend: return an error when an output has no buffer (#24640) ### What goes wrong today Some CUDA programs keep their outputs on the GPU and do not memory plan them. For such an output, ExecuTorch expects the C++ app to give it a buffer with `Module::set_output` or `Method::set_output_data_ptr` before `execute`. If the app forgets, the CUDA backend does not return an error. It wraps the null pointer and aborts the whole process: ``` from_blob: data pointer cannot be nullptr Aborted (core dumped) ``` The Python runtime allocates these outputs itself, so this mostly affects C++ apps. ### What this change does `execute()` already checks that every delegate input and output is a CUDA tensor backed by device memory, but it skipped tensors with a null pointer. Now a null pointer makes `execute()` return `Error::InvalidArgument`, with a log line that names the method and the delegate input or output. For an output it also says how to give it a buffer: ``` Method 'forward': CUDA delegate output 0 has no data buffer. If it is a method output that is not memory planned, set a CUDA buffer for it with Method::set_output_data_ptr or Module::set_output. ``` The check runs before the compiled program runs or any cached state is updated. Calls where every input and output has a buffer run as before. The new C++ test `test_cuda_backend_execute` calls the registered CUDA backend with a stand-in compiled program. It is added to the Linux and Windows CUDA runtime test jobs. ### Testing On Linux x86_64 with an A100: - Without the fix, the new test binary aborts with the same `from_blob` message. With the fix, all 4 of its cases pass: a missing output buffer, a missing input buffer, and a host buffer passed as a CUDA input each return `InvalidArgument`, and an output with a buffer still runs. - All 13 `test_cuda_*` runtime tests pass with the fix. `test_cuda_executor` skips most of its cases there, because no exported model was set up for it. On Linux x86_64 with an H100, a real exported CUDA model whose output stays on the GPU, run from a C++ app: - Without `set_output`: before the fix the process aborts; with the fix, `execute` returns error `0x12` (InvalidArgument) to the app instead of crashing it. - With `set_output`: the output matches the Python runtime exactly, before and after the fix. clang-format and cmake-format are clean on the changed files. --- .ci/scripts/test_cuda_runtime_windows.ps1 | 1 + .github/workflows/cuda.yml | 3 +- backends/cuda/CMakeLists.txt | 6 + backends/cuda/runtime/cuda_backend.cpp | 57 +++++--- .../test/test_cuda_backend_execute.cpp | 127 ++++++++++++++++++ 5 files changed, 171 insertions(+), 23 deletions(-) create mode 100644 backends/cuda/runtime/test/test_cuda_backend_execute.cpp diff --git a/.ci/scripts/test_cuda_runtime_windows.ps1 b/.ci/scripts/test_cuda_runtime_windows.ps1 index 5a11df96895..3e86a8da920 100644 --- a/.ci/scripts/test_cuda_runtime_windows.ps1 +++ b/.ci/scripts/test_cuda_runtime_windows.ps1 @@ -34,6 +34,7 @@ $tests = @( "test_cuda_mutable_state", "test_cuda_weight_cache", "test_cuda_output_copy", + "test_cuda_backend_execute", "test_cuda_sort_rand", "test_cuda_current_stream", "test_cuda_guard", diff --git a/.github/workflows/cuda.yml b/.github/workflows/cuda.yml index 318664a4dcf..525eef539fc 100644 --- a/.github/workflows/cuda.yml +++ b/.github/workflows/cuda.yml @@ -782,11 +782,12 @@ jobs: -v -o "addopts=" cmake --preset llm-release-cuda -DEXECUTORCH_BUILD_TESTS=ON - cmake --build cmake-out --target test_cuda_allocator test_cuda_allocator_exit test_cuda_mutable_state test_cuda_weight_cache test_cuda_output_copy test_cuda_sort_rand test_cuda_current_stream test_cuda_kv_cache test_cuda_guard test_cuda_stream_guard test_step_plan test_cuda_executor -j$(nproc) + cmake --build cmake-out --target test_cuda_allocator test_cuda_allocator_exit test_cuda_mutable_state test_cuda_weight_cache test_cuda_output_copy test_cuda_backend_execute test_cuda_sort_rand test_cuda_current_stream test_cuda_kv_cache test_cuda_guard test_cuda_stream_guard test_step_plan test_cuda_executor -j$(nproc) ctest --test-dir cmake-out -R test_cuda_allocator --output-on-failure -V ctest --test-dir cmake-out -R test_cuda_mutable_state --output-on-failure -V ctest --test-dir cmake-out -R test_cuda_weight_cache --output-on-failure -V ctest --test-dir cmake-out -R test_cuda_output_copy --output-on-failure -V + ctest --test-dir cmake-out -R test_cuda_backend_execute --output-on-failure -V ctest --test-dir cmake-out -R test_cuda_sort_rand --output-on-failure -V ctest --test-dir cmake-out -R test_cuda_current_stream --output-on-failure -V ctest --test-dir cmake-out -R test_cuda_kv_cache --output-on-failure -V diff --git a/backends/cuda/CMakeLists.txt b/backends/cuda/CMakeLists.txt index b5cb4b3cf09..770aba4f130 100644 --- a/backends/cuda/CMakeLists.txt +++ b/backends/cuda/CMakeLists.txt @@ -499,6 +499,12 @@ if(BUILD_TESTING) EXTRA_LIBS aoti_cuda_backend ) target_compile_definitions(test_cuda_output_copy PRIVATE CUDA_AVAILABLE=1) + + et_cxx_test( + test_cuda_backend_execute SOURCES + runtime/test/test_cuda_backend_execute.cpp EXTRA_LIBS aoti_cuda_backend + ) + target_compile_definitions(test_cuda_backend_execute PRIVATE CUDA_AVAILABLE=1) # sort.cu and rand.cu are compiled only under this condition (see # _aoti_cuda_shim_sources), so the test that calls them is too. if(NOT EXECUTORCH_BUILD_ROCM AND CMAKE_CUDA_COMPILER) diff --git a/backends/cuda/runtime/cuda_backend.cpp b/backends/cuda/runtime/cuda_backend.cpp index 610f7c46b77..ec73530894e 100644 --- a/backends/cuda/runtime/cuda_backend.cpp +++ b/backends/cuda/runtime/cuda_backend.cpp @@ -722,15 +722,16 @@ class ET_EXPERIMENTAL CudaBackend final n_outputs, args.size()) - // Verify device info on all memory-planned, ET-driven IO tensors. + // Verify device info on all ET-driven IO tensors. // All input and output tensors should have device_type = CUDA, which // is set during serialization by PropagateDevicePass based on the // target_device compile spec from CudaPartitioner. // // Under device memory planning, these tensors are GPU-resident: their - // storage lives in a planned CUDA arena. The backend wraps them in place - // and performs no host<->device copies — graph-level et_copy ops handle - // any host<->device transfers outside the delegate. + // storage lives in a planned CUDA arena, or in a buffer the caller set for + // an input or output that is not memory planned. The backend wraps them + // in place and performs no host<->device copies; graph-level et_copy ops + // handle any host<->device transfers outside the delegate. for (size_t i = 0; i < n_inputs + n_outputs; i++) { auto* tensor = &(args[i]->toTensor()); auto device_type = tensor->unsafeGetTensorImpl()->device_type(); @@ -742,27 +743,39 @@ class ET_EXPERIMENTAL CudaBackend final i, static_cast(device_type)); + // A method output that is not memory planned has no buffer until the + // caller sets one, and the run below cannot wrap a null pointer. + const void* data_ptr = tensor->const_data_ptr(); + ET_CHECK_OR_RETURN_ERROR( + data_ptr != nullptr, + InvalidArgument, + "Method '%s': CUDA delegate %s %zu has no data buffer.%s", + handle->method_name.c_str(), + i < n_inputs ? "input" : "output", + i < n_inputs ? i : i - n_inputs, + i < n_inputs ? "" + : " If it is a method output that is not memory " + "planned, set a CUDA buffer for it with " + "Method::set_output_data_ptr or Module::set_output."); + // device_type above is only metadata. Also verify the storage actually // lives in CUDA device memory, so a CUDA-typed tensor that is secretly // backed by host memory is caught here instead of corrupting the run. - const void* data_ptr = tensor->const_data_ptr(); - if (data_ptr != nullptr) { - cudaPointerAttributes attributes{}; - const cudaError_t attr_err = - cudaPointerGetAttributes(&attributes, data_ptr); - ET_CHECK_OR_RETURN_ERROR( - attr_err == cudaSuccess && - (attributes.type == cudaMemoryTypeDevice || - attributes.type == cudaMemoryTypeManaged), - InvalidArgument, - "Tensor %zu has device_type=CUDA but its data pointer %p is not " - "backed by CUDA device memory (cudaPointerGetAttributes err=%d, " - "cudaMemoryType=%d).", - i, - data_ptr, - static_cast(attr_err), - static_cast(attributes.type)); - } + cudaPointerAttributes attributes{}; + const cudaError_t attr_err = + cudaPointerGetAttributes(&attributes, data_ptr); + ET_CHECK_OR_RETURN_ERROR( + attr_err == cudaSuccess && + (attributes.type == cudaMemoryTypeDevice || + attributes.type == cudaMemoryTypeManaged), + InvalidArgument, + "Tensor %zu has device_type=CUDA but its data pointer %p is not " + "backed by CUDA device memory (cudaPointerGetAttributes err=%d, " + "cudaMemoryType=%d).", + i, + data_ptr, + static_cast(attr_err), + static_cast(attributes.type)); } // The cache has to grow, and its storage be rebound, before the compiled diff --git a/backends/cuda/runtime/test/test_cuda_backend_execute.cpp b/backends/cuda/runtime/test/test_cuda_backend_execute.cpp new file mode 100644 index 00000000000..ecdaef11f7e --- /dev/null +++ b/backends/cuda/runtime/test/test_cuda_backend_execute.cpp @@ -0,0 +1,127 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include +#include +#include +#include + +#include + +namespace aoti = ::executorch::backends::aoti; +using namespace ::executorch::runtime; +using namespace ::executorch::backends::cuda; + +namespace { + +bool program_ran = false; + +Error one_tensor(aoti::AOTInductorModelContainerHandle, size_t* count) { + *count = 1; + return Error::Ok; +} + +// Stands in for the compiled program. Like the real one, it takes ownership of +// the input handles; it leaves the output handles in place. +Error fake_run( + aoti::AOTInductorModelContainerHandle, + aoti::Tensor** inputs, + size_t num_inputs, + aoti::Tensor**, + size_t, + aoti::AOTInductorStreamHandle, + aoti::AOTIProxyExecutorHandle) { + program_ran = true; + for (size_t i = 0; i < num_inputs; ++i) { + delete reinterpret_cast(inputs[i]); + } + return Error::Ok; +} + +class CudaBackendExecuteTest : public ::testing::Test { + protected: + void SetUp() override { + et_pal_init(); + int count = 0; + if (cudaGetDeviceCount(&count) != cudaSuccess || count == 0) { + GTEST_SKIP() << "CUDA not available"; + } + backend_ = get_backend_class(kCudaBackendId); + ASSERT_NE(backend_, nullptr); + handle_.get_num_inputs = one_tensor; + handle_.get_num_outputs = one_tensor; + handle_.run = fake_run; + ASSERT_EQ(cudaMalloc(&input_, sizeof(float) * 4), cudaSuccess); + ASSERT_EQ(cudaMalloc(&output_, sizeof(float) * 4), cudaSuccess); + program_ran = false; + } + + void TearDown() override { + cudaFree(input_); + cudaFree(output_); + } + + Error execute(void* input_data, void* output_data) { + etensor::TensorImpl::SizesType sizes[] = {4}; + etensor::TensorImpl::DimOrderType dim_order[] = {0}; + etensor::TensorImpl::StridesType strides[] = {1}; + etensor::TensorImpl input_impl( + etensor::ScalarType::Float, + 1, + sizes, + input_data, + dim_order, + strides, + TensorShapeDynamism::STATIC, + etensor::DeviceType::CUDA); + etensor::TensorImpl output_impl( + etensor::ScalarType::Float, + 1, + sizes, + output_data, + dim_order, + strides, + TensorShapeDynamism::STATIC, + etensor::DeviceType::CUDA); + EValue input{etensor::Tensor(&input_impl)}; + EValue output{etensor::Tensor(&output_impl)}; + EValue* args[] = {&input, &output}; + BackendExecutionContext context; + return backend_->execute(context, &handle_, Span(args, 2)); + } + + BackendInterface* backend_ = nullptr; + CudaDelegateHandle handle_{}; + void* input_ = nullptr; + void* output_ = nullptr; +}; + +// An output that is not memory planned has no buffer until the caller sets +// one. Running without it has to fail the call, not abort the process. +TEST_F(CudaBackendExecuteTest, OutputWithoutBufferIsAnError) { + EXPECT_EQ(execute(input_, nullptr), Error::InvalidArgument); + EXPECT_FALSE(program_ran); +} + +TEST_F(CudaBackendExecuteTest, InputWithoutBufferIsAnError) { + EXPECT_EQ(execute(nullptr, output_), Error::InvalidArgument); + EXPECT_FALSE(program_ran); +} + +TEST_F(CudaBackendExecuteTest, InputInHostMemoryIsAnError) { + float host_input[4] = {}; + EXPECT_EQ(execute(host_input, output_), Error::InvalidArgument); + EXPECT_FALSE(program_ran); +} + +TEST_F(CudaBackendExecuteTest, OutputWithBufferRuns) { + EXPECT_EQ(execute(input_, output_), Error::Ok); + EXPECT_TRUE(program_ran); +} + +} // namespace From 38ba5daf873b33dc70dd1b39bd0d61ce538df543 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Wed, 4 Nov 2026 21:37:04 -0800 Subject: [PATCH 078/128] Keep the view after a linear when only its shape bounds match (#24626) ## What is wrong today `AddmmToLinearTransform` removes the view after a linear when the linear already produces that shape. It compares upper bounds, and two different dynamic shapes can have the same bounds. Example: a dequantized input `[a, b, 16]`, with `a` and `b` both in `[2, 8]`, goes through `view(-1, 16)`, a linear, and `view(b, a, 8)`. The transform points the linear at the dequantized tensor, so its output `[a, b, 8]` and the view `[b, a, 8]` both have the bounds `[8, 8, 8]`. The view is removed and the output has the wrong shape. The transform points a linear at the dequantized tensor even when no view after it is removed. That changes its output shape: `view(-1, 16) -> linear` on a `[2, 3, 16]` input returns `[2, 3, 8]` instead of `[6, 8]`. XNNPACK, Vulkan and Samsung lowering run this transform, and so does the LLM export through `ConvertToLinearPass`. ## What this change does 1. The transform compares the symbolic shapes, and removes the view only when they are known to be equal. XNNPACK's `ConvertToLinearPass` now gives each linear it makes the value of the op it replaces, not a fixed-size tensor, so a no-op view after a second linear still goes. 2. The linear reads the dequantized tensor only when the view after it is removed in the same step, and it takes that view's shape. The dequantized tensor no longer needs a single reader, so two linears that read it through their own views both lose their views. ## Testing - New `backends/transforms/test/test_addmm_mm_to_linear.py`: the swapped view is kept, with bounded and unbounded dims; a view back to the linear's shape is still removed, and the linear takes its value; each linear reads the dequantized tensor only when its view goes, not for a view that only restates its shape; two linears sharing one dequantized tensor both lose their views. Each checks the graph and compares with eager, and each fails on main. - New cases in `backends/xnnpack/test/passes/test_convert_to_linear.py`: a no-op view after two linears with a dynamic batch is removed, as on main; the swapped view after a linear is kept with both dims dynamic (fails on main). - Byte-identical programs before and after: 5 small static models lowered with XNNPACK and Vulkan, and 8 dynamic-shape programs built the way the LLM export does (float and dynamically quantized, with and without XNNPACK). --- backends/transforms/addmm_mm_to_linear.py | 59 +++--- backends/transforms/targets.bzl | 11 ++ .../test/test_addmm_mm_to_linear.py | 180 ++++++++++++++++++ backends/xnnpack/_passes/convert_to_linear.py | 3 +- .../test/passes/test_convert_to_linear.py | 54 ++++++ 5 files changed, 276 insertions(+), 31 deletions(-) create mode 100644 backends/transforms/test/test_addmm_mm_to_linear.py diff --git a/backends/transforms/addmm_mm_to_linear.py b/backends/transforms/addmm_mm_to_linear.py index 358cbb7ac14..cb4b8e36001 100644 --- a/backends/transforms/addmm_mm_to_linear.py +++ b/backends/transforms/addmm_mm_to_linear.py @@ -9,6 +9,7 @@ from executorch.exir.pass_base import ExportPass, PassResult from executorch.exir.sym_util import eval_shape, eval_shape_upper_bound +from torch.fx.experimental.symbolic_shapes import statically_known_true, sym_eq _int64_max_dim_val = torch.iinfo(torch.int64).max - 1 @@ -66,35 +67,35 @@ def replace_linear_view_copy_input_output(graph: torch.fx.Graph) -> torch.fx.Gra """ ops = exir_ops.edge for node in graph.nodes: - if node.op == "call_function" and (node.target == ops.aten.linear.default): - input_node = node.args[0] - dqlinear_input = get_dqlinear_input(input_node) - if dqlinear_input is not None and dqlinear_input != input_node: - if len(input_node.args[0].users) == 1: - input_node.replace_all_uses_with(dqlinear_input) - else: - print( - f"{input_node} has more than one user. Users: {input_node.users}" - ) - if len(node.users) == 1: - users = list(node.users) - maybe_view_copy = users[0] - if maybe_view_copy.op == "call_function" and ( - maybe_view_copy.target == ops.aten.view_copy.default - ): - # Must update the input node since replaced the original node - input_node = node.args[0] - input_shape = list(get_shape(input_node)) - weight_node = node.args[1] - if "val" not in weight_node.meta: - raise ValueError(f"Val not found meta of node {weight_node}") - weight_val = weight_node.meta["val"] - output_channels = weight_val.shape[0] - output_shape = input_shape - output_shape[-1] = output_channels - view_copy_out_shape = list(get_shape(maybe_view_copy)) - if output_shape == view_copy_out_shape: - maybe_view_copy.replace_all_uses_with(node) + if node.op != "call_function" or node.target != ops.aten.linear.default: + continue + if len(node.users) != 1: + continue + view_copy = next(iter(node.users)) + if view_copy.target != ops.aten.view_copy.default: + continue + weight_node = node.args[1] + if "val" not in weight_node.meta: + raise ValueError(f"Val not found meta of node {weight_node}") + output_channels = weight_node.meta["val"].shape[0] + input_node = node.args[0] + dqlinear_input = get_dqlinear_input(input_node) + # Reading the dequantized input before its views changes the shape of the + # linear output, so it is done only when the view after the linear goes. + for new_input in (dqlinear_input, input_node): + if new_input is None: + continue + output_shape = list(new_input.meta["val"].shape) + output_shape[-1] = output_channels + # Compare the symbolic shapes, since different dynamic shapes can + # have equal upper bounds. + if statically_known_true( + sym_eq(output_shape, list(view_copy.meta["val"].shape)) + ): + node.replace_input_with(input_node, new_input) + node.meta["val"] = view_copy.meta["val"] + view_copy.replace_all_uses_with(node) + break graph.eliminate_dead_code() return graph diff --git a/backends/transforms/targets.bzl b/backends/transforms/targets.bzl index f4456cc2812..d8f89cd5381 100644 --- a/backends/transforms/targets.bzl +++ b/backends/transforms/targets.bzl @@ -33,6 +33,17 @@ def define_common_targets(): ], ) + runtime.python_test( + name = "test_addmm_mm_to_linear", + srcs = ["test/test_addmm_mm_to_linear.py"], + deps = [ + ":addmm_mm_to_linear", + "//caffe2:torch", + "//executorch/exir:lib", + "//executorch/exir/dialects:lib", + ], + ) + runtime.python_library( name = "decompose_sdpa", srcs = ["decompose_sdpa.py"], diff --git a/backends/transforms/test/test_addmm_mm_to_linear.py b/backends/transforms/test/test_addmm_mm_to_linear.py new file mode 100644 index 00000000000..2b3fbf4e4fa --- /dev/null +++ b/backends/transforms/test/test_addmm_mm_to_linear.py @@ -0,0 +1,180 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import unittest + +import torch +from executorch.backends.transforms.addmm_mm_to_linear import ( + apply_addmm_mm_to_linear_transform, +) +from executorch.exir import to_edge +from executorch.exir.dialects._ops import ops as exir_ops +from torch.export import Dim + + +def _dequantize(xq: torch.Tensor) -> torch.Tensor: + return torch.ops.quantized_decomposed.dequantize_per_tensor.tensor( + xq, torch.tensor(0.1), torch.tensor(0), -128, 127, torch.int8 + ) + + +def _int8(*shape: int) -> torch.Tensor: + return torch.randint(-128, 127, shape, dtype=torch.int8) + + +class _DequantizeLinearView(torch.nn.Module): + def __init__(self, swap: bool) -> None: + super().__init__() + self.linear = torch.nn.Linear(16, 8) + self.swap = swap + + def forward(self, xq: torch.Tensor) -> torch.Tensor: + a, b = xq.shape[0], xq.shape[1] + y = self.linear(_dequantize(xq).view(-1, 16)) + return y.view(b, a, 8) if self.swap else y.view(a, b, 8) + + +class _DequantizeLinear(torch.nn.Module): + def __init__(self) -> None: + super().__init__() + self.linear = torch.nn.Linear(16, 8) + + def forward(self, xq: torch.Tensor) -> torch.Tensor: + return self.linear(_dequantize(xq).view(-1, 16)) + + +class _DequantizeLinearNoOpView(torch.nn.Module): + def __init__(self) -> None: + super().__init__() + self.linear = torch.nn.Linear(16, 8) + + def forward(self, xq: torch.Tensor) -> torch.Tensor: + return self.linear(_dequantize(xq).view(-1, 16)).view(-1, 8) + + +class _DequantizeLinearViewAndFlatInput(torch.nn.Module): + def __init__(self) -> None: + super().__init__() + self.linear = torch.nn.Linear(16, 8) + + def forward(self, xq: torch.Tensor): + flat = _dequantize(xq).view(-1, 16) + y = self.linear(flat).view(xq.shape[0], xq.shape[1], 8) + return y, flat * 2 + + +class _DequantizeTwoLinearViews(torch.nn.Module): + def __init__(self) -> None: + super().__init__() + self.q = torch.nn.Linear(16, 8) + self.k = torch.nn.Linear(16, 8) + + def forward(self, xq: torch.Tensor): + x = _dequantize(xq) + a, b = xq.shape[0], xq.shape[1] + return ( + self.q(x.view(-1, 16)).view(a, b, 8), + self.k(x.view(-1, 16)).view(a, b, 8), + ) + + +_BOUNDED = ({0: Dim("a", min=2, max=8), 1: Dim("b", min=2, max=8)},) +_UNBOUNDED = ({0: Dim("a"), 1: Dim("b")},) + + +_DEQUANTIZE = exir_ops.edge.quantized_decomposed.dequantize_per_tensor.tensor +_LINEAR = exir_ops.edge.aten.linear.default +_VIEW = exir_ops.edge.aten.view_copy.default + + +class TestAddmmMmToLinear(unittest.TestCase): + def _transform(self, model, example, dynamic_shapes=None): + ep = torch.export.export(model, (example,), dynamic_shapes=dynamic_shapes) + program = to_edge(ep).exported_program() + apply_addmm_mm_to_linear_transform(program.graph_module.graph) + return program + + def _linears(self, program): + return [n for n in program.graph_module.graph.nodes if n.target == _LINEAR] + + def test_view_to_the_linear_output_shape_is_removed(self): + model = _DequantizeLinearView(swap=False) + for name, dynamic_shapes in ( + ("static", None), + ("bounded", _BOUNDED), + ("unbounded", _UNBOUNDED), + ): + with self.subTest(name): + ep = torch.export.export( + model, (_int8(2, 3, 16),), dynamic_shapes=dynamic_shapes + ) + program = to_edge(ep).exported_program() + graph = program.graph_module.graph + view_val = graph.output_node().args[0][0].meta["val"] + apply_addmm_mm_to_linear_transform(graph) + output = graph.output_node().args[0][0] + self.assertEqual(output.target, _LINEAR) + self.assertEqual(output.args[0].target, _DEQUANTIZE) + self.assertIs(output.meta["val"], view_val) + x = _int8(2, 3, 16) + torch.testing.assert_close(program.module()(x), model(x)) + + def test_view_to_another_shape_with_equal_bounds_is_kept(self): + model = _DequantizeLinearView(swap=True) + # [b, a, 8] and the linear output [a, b, 8] have the same upper bounds, + # and without upper bounds the same example sizes. + for name, example, dynamic_shapes in ( + ("bounded", _int8(2, 3, 16), _BOUNDED), + ("unbounded", _int8(3, 3, 16), _UNBOUNDED), + ): + with self.subTest(name): + program = self._transform(model, example, dynamic_shapes) + output = program.graph_module.graph.output_node().args[0][0] + self.assertEqual(output.target, _VIEW) + (linear,) = self._linears(program) + self.assertEqual(linear.args[0].target, _VIEW) + x = _int8(2, 3, 16) + torch.testing.assert_close(program.module()(x), model(x)) + + def test_linear_reads_the_dequantized_input_only_when_its_view_goes(self): + # The first linear has no view after it, so it keeps reading the + # flattened input. The second has a view only to its own output shape, + # which goes, while the linear keeps reading the flattened input. The + # third reads the dequantized tensor once its view goes, while the other + # reader of the flattened input keeps its shape. + for model, reads in ( + (_DequantizeLinear(), _VIEW), + (_DequantizeLinearNoOpView(), _VIEW), + (_DequantizeLinearViewAndFlatInput(), _DEQUANTIZE), + ): + for name, dynamic_shapes in (("static", None), ("bounded", _BOUNDED)): + with self.subTest(type(model).__name__, shapes=name): + x = _int8(2, 3, 16) + program = self._transform(model, x, dynamic_shapes) + (linear,) = self._linears(program) + self.assertEqual(linear.args[0].target, reads) + self.assertIs( + program.graph_module.graph.output_node().args[0][0], linear + ) + torch.testing.assert_close(program.module()(x), model(x)) + + def test_linears_sharing_a_dequantized_input_both_lose_their_views(self): + model = _DequantizeTwoLinearViews() + for name, dynamic_shapes in (("static", None), ("bounded", _BOUNDED)): + with self.subTest(name): + x = _int8(2, 3, 16) + program = self._transform(model, x, dynamic_shapes) + linears = self._linears(program) + self.assertEqual(len(linears), 2) + self.assertTrue(all(n.args[0].target == _DEQUANTIZE for n in linears)) + self.assertFalse( + any(n.target == _VIEW for n in program.graph_module.graph.nodes) + ) + torch.testing.assert_close(program.module()(x), model(x)) + + +if __name__ == "__main__": + unittest.main() diff --git a/backends/xnnpack/_passes/convert_to_linear.py b/backends/xnnpack/_passes/convert_to_linear.py index 8259aae4e98..91481da60ce 100644 --- a/backends/xnnpack/_passes/convert_to_linear.py +++ b/backends/xnnpack/_passes/convert_to_linear.py @@ -142,8 +142,7 @@ def create_linear( exir_ops.edge.aten.linear.default, # HACK not edge_op/CATen args, ) - # TODO - calculate output even when dynamic_shape=True - linear_node.meta["val"] = torch.zeros(get_shape(output)) + linear_node.meta["val"] = output.meta["val"] logger.debug( f"Replacing {output}{get_shape(output)} node with {linear_node}{get_shape(linear_node)}" ) diff --git a/backends/xnnpack/test/passes/test_convert_to_linear.py b/backends/xnnpack/test/passes/test_convert_to_linear.py index 0e7bc7d01c4..67fb6819920 100644 --- a/backends/xnnpack/test/passes/test_convert_to_linear.py +++ b/backends/xnnpack/test/passes/test_convert_to_linear.py @@ -41,3 +41,57 @@ def test_fp32_convert_to_linear(self): ) .run_method_and_compare_outputs() ) + + def test_fp32_view_after_two_linears_with_dynamic_shapes(self): + class Model(torch.nn.Module): + def __init__(self): + super().__init__() + self.fc1 = torch.nn.Linear(16, 16) + self.fc2 = torch.nn.Linear(16, 8) + + def forward(self, x): + return self.fc2(self.fc1(x)).view(-1, 8) + + dynamic_shapes = ({0: torch.export.Dim("batch", min=1, max=32)},) + ( + Tester(Model(), (torch.randn(4, 16),), dynamic_shapes=dynamic_shapes) + .export() + .to_edge() + .run_passes(self.PassStage) + .check_count({"executorch_exir_dialects_edge__ops_aten_linear_default": 2}) + # The view does not change the shape, so it goes. + .check_not(["executorch_exir_dialects_edge__ops_aten_view_copy_default"]) + .run_method_and_compare_outputs() + ) + + def test_fp32_view_to_another_shape_with_equal_bounds_is_kept(self): + class Model(torch.nn.Module): + def __init__(self): + super().__init__() + self.linear = torch.nn.Linear(16, 8) + + def forward(self, x): + return self.linear(x).view(x.shape[1], x.shape[0], 8) + + x = torch.randn(2, 5, 16) + dynamic_shapes = ( + { + 0: torch.export.Dim("a", min=2, max=8), + 1: torch.export.Dim("b", min=2, max=8), + }, + ) + ( + Tester(Model(), (x,), dynamic_shapes=dynamic_shapes) + .export() + .to_edge() + .run_passes(self.PassStage) + .check_count( + { + "executorch_exir_dialects_edge__ops_aten_linear_default": 1, + # [b, a, 8] has the bounds of the linear output [a, b, 8], but + # not its shape, so the view stays. + "executorch_exir_dialects_edge__ops_aten_view_copy_default": 1, + } + ) + .run_method_and_compare_outputs(inputs=(x,)) + ) From e8a6764869eef0b7cebf59bf9f81da84e31a5f99 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Wed, 4 Nov 2026 22:00:53 -0800 Subject: [PATCH 079/128] OpenVINO: keep copies of constants in the partition, and copy inputs a whole program returns (#24506) ### What is wrong today OpenVINO turns a copy like `x.clone()` into nothing, so a model returning such a copy returns its input, and the CPU plugin gives wrong values for it. A recent change ran these copies outside OpenVINO. It also moved copies of weights and buffers out. Most did not need it, since a constant is stored inside the partition, not passed in. That had costs: ```python class M(torch.nn.Module): def __init__(self): super().__init__() self.register_buffer("buf", torch.randn(256 * 1024)) def forward(self, x): return self.buf.clone(), x + self.buf ``` - The 1 MiB buffer was stored twice, doubling the program. - With the buffer marked `no_copy`, export failed. - With dim order off, `self.buf.clone(memory_format=torch.preserve_format)` failed at run time. A whole program lowered with `to_backend` still gave wrong values for `return x.clone()`. ### What this change does - The partitioner leaves a copy of a constant inside the partition when: - only this partition reads the constant, - no other output of the partition is a copy of the same constant, - the copy keeps the default dim order, - the runtime can return its type, and it is not 64-bit, which the CPU plugin computes in 32 bits, - for a float copy, the precision is `f32`, the CPU default. At `f16`, OpenVINO on an ARM CPU rounded such a copy. - On the CPU, `preprocess` puts an OpenVINO `Identity` between an input and an output when the converted model would return the input. This fixes whole programs. - The minimum OpenVINO version moves from 2025.1 to 2025.4. 2025.4 is the first release whose CPU plugin has `Identity`. On Linux aarch64, OpenVINO now needs glibc 2.35, as on Ubuntu 22.04 or Raspberry Pi OS Bookworm. Older systems get an older ExecuTorch, as the docs warn. - The docs now say `f16` can run in bfloat16 on an x86 CPU. Not changed: a whole program's int64 output outside the int32 range still comes back wrong, like every int64 tensor on the CPU. At `f16` on an ARM CPU, OpenVINO rounds the `Identity` copy of a float input, like any other float output. ### Testing - New tests in `backends/openvino/tests/ops/test_copy.py` check where copies run, that each kept type comes back exact, mutated buffers, and whole programs with several dtypes and shapes. `test_copy_of_constant_as_output` and `test_whole_program_returning_copy_of_input` fail on main. - All OpenVINO op tests pass on Linux x86_64 with OpenVINO 2026.4.1 and 2025.4.0. `test_copy.py` passes at each commit, on x86_64 with 2025.4.0 and on aarch64 with both. - Not tested on GPU or NPU. --- README-wheel.md | 7 +- backends/openvino/README.md | 9 +- backends/openvino/partitioner.py | 113 ++++++-- backends/openvino/preprocess.py | 32 ++- backends/openvino/runtime/OpenvinoBackend.cpp | 2 +- .../tests/ops/base_openvino_op_test.py | 42 +-- backends/openvino/tests/ops/test_copy.py | 248 +++++++++++++++++- docs/source/build-run-openvino.md | 16 +- docs/source/using-executorch-cpp.md | 8 +- pyproject.toml | 8 +- 10 files changed, 418 insertions(+), 67 deletions(-) diff --git a/README-wheel.md b/README-wheel.md index 5245e312bcd..efc089dee84 100644 --- a/README-wheel.md +++ b/README-wheel.md @@ -37,8 +37,11 @@ to run ExecuTorch `.pte` files, with some restrictions: * \[Linux and Windows] [OpenVINO](docs/source/build-run-openvino.md) backend is also linked into the prebuilt module. OpenVINO requires the runtime to be installed separately: `pip install executorch[openvino]`. For now the extra installs no runtime on Python 3.15, - or on Linux aarch64 with Python 3.14. OpenVINO publishes no build for Python 3.15, and its - aarch64 builds for Python 3.14 need glibc 2.35 or newer. + where OpenVINO publishes no build. On Linux aarch64, OpenVINO needs glibc 2.35 or newer, + as on Ubuntu 22.04 or Raspberry Pi OS Bookworm and later. On an older aarch64 system, such as + RHEL 9 or Amazon Linux 2023, `pip install executorch[openvino]` does not fail but installs an + older ExecuTorch release; see the [OpenVINO docs](docs/source/build-run-openvino.md) for what + to do there. On Linux, macOS and Windows the runtime, kernels and backends also ship as shared libraries with headers and a CMake package, so a C++ application can link them without building diff --git a/backends/openvino/README.md b/backends/openvino/README.md index 883e1e285f6..743a40ba314 100644 --- a/backends/openvino/README.md +++ b/backends/openvino/README.md @@ -20,7 +20,7 @@ On Linux and Windows, the OpenVINO backend is included in the ExecuTorch pip whe pip install executorch[openvino] ``` -For now the extra installs no runtime on Python 3.15, or on Linux aarch64 with Python 3.14. OpenVINO publishes no build for Python 3.15, and its aarch64 builds for Python 3.14 need glibc 2.35 or newer. +For now the extra installs no runtime on Python 3.15, where OpenVINO publishes no build. On Linux aarch64, OpenVINO needs glibc 2.35 or newer, as on Ubuntu 22.04 or Raspberry Pi OS Bookworm and later. On an older aarch64 system, `pip install executorch[openvino]` does not fail: pip finds no OpenVINO 2025.4 or newer for that system, so it installs an older ExecuTorch release instead. On such a system, install `executorch` without the extra, then build OpenVINO from source as described below. The backend automatically discovers the OpenVINO C library from the pip-installed package, so no library search path setup is needed. @@ -70,7 +70,8 @@ float16, as measured on an Orin Nano. The value is OpenVINO's `INFERENCE_PRECISION_HINT`: `f32`, `f16` or `bf16`. An empty value means the default, and any other value fails at lowering. A CPU -without float16 support computes an `f16` program in float32. OpenVINO has no +without float16 support computes an `f16` program in bfloat16 if it has bfloat16 +units, as some x86 CPUs do, and in float32 otherwise. OpenVINO has no bfloat16 on ARM CPUs, so there `bf16` keeps the ARM default, float16 where the CPU supports it. Which x86 CPUs compute in bfloat16 is up to OpenVINO; see its [Precision Control](https://docs.openvino.ai/2026/openvino-workflow/running-inference/optimize-inference/precision-control.html) @@ -139,7 +140,7 @@ executorch ### Prerequisites -Before you begin, ensure you have openvino installed and configured on your system. +Before you begin, ensure you have OpenVINO 2025.4 or newer installed and configured on your system. ### Use OpenVINO from Release Packages @@ -157,7 +158,7 @@ Before you begin, ensure you have openvino installed and configured on your syst ```bash git clone https://github.com/openvinotoolkit/openvino.git -cd openvino +cd openvino && git checkout releases/2026/4 git submodule update --init --recursive sudo ./install_build_dependencies.sh mkdir build && cd build diff --git a/backends/openvino/partitioner.py b/backends/openvino/partitioner.py index 4d6cfd2f93e..b78a114b822 100644 --- a/backends/openvino/partitioner.py +++ b/backends/openvino/partitioner.py @@ -6,10 +6,14 @@ # mypy: disable-error-code=import-not-found +from collections import Counter from typing import Callable, final, List, Optional, Tuple import torch -from executorch.backends.openvino.preprocess import OpenvinoBackend +from executorch.backends.openvino.preprocess import ( + _inference_precision, + OpenvinoBackend, +) from executorch.exir.backend.backend_details import CompileSpec from executorch.exir.backend.partitioner import ( DelegationSpec, @@ -32,6 +36,19 @@ "dim_order_ops._clone_dim_order.default", } +# The runtime binds outputs of these types, and the CPU plugin computes 64-bit +# types in 32 bits, so only copies of these types can return a constant. +_CONSTANT_COPY_DTYPES = { + torch.bool, + torch.uint8, + torch.int8, + torch.int16, + torch.int32, + torch.float16, + torch.bfloat16, + torch.float32, +} + class PatternNode: op_types: dict[str, Optional[list]] = {} @@ -56,32 +73,84 @@ def _copy_source(node: torch.fx.Node) -> Optional[torch.fx.Node]: return source if isinstance(source, torch.fx.Node) else None -def _remove_copies_of_inputs(partition: Partition) -> None: - """Leave out of the partition each copy that would return one of the - partition's inputs, unchanged, as one of its outputs. +def _constants(program: ExportedProgram) -> set[torch.fx.Node]: + """The placeholders that each partition reading them stores as its own + data (see tag_constant_data), so OpenVINO gets them as constants, not as + inputs.""" + signature = program.graph_signature + mutated = set(signature.buffers_to_mutate.values()) + names = { + *signature.inputs_to_parameters, + *signature.inputs_to_lifted_tensor_constants, + *( + name + for name, buffer in signature.inputs_to_buffers.items() + if buffer not in mutated + ), + } + return { + node + for node in program.graph.nodes + if node.op == "placeholder" and node.name in names + } + + +def _remove_copies_of_inputs( + partition: Partition, constants: set[torch.fx.Node], dtypes: set[torch.dtype] +) -> None: + """Leave out of the partition each copy that would return, unchanged, one + of the partition's inputs or a constant OpenVINO cannot return. OpenVINO would return the input itself, and when the runtime binds the output buffers after the inputs, its CPU plugin does not write such an output and can overwrite the input with the output buffer's old contents. Copies used only inside the partition stay, because OpenVINO reads every input in the default dim order, which a copy left out could have changed. + A copy of a constant that only this partition reads stays too, since the + constant is not a partition input. Such a copy still leaves when it is not + in the default dim order, since OpenVINO writes every output in the default + one, when its type is not in dtypes, the types OpenVINO can return, or when + another output also returns that constant, since OpenVINO's PyTorch + frontend cannot convert two outputs of one constant. Like any input, a copy + left out that is not in the default dim order is still read wrong by the + ops that use it inside. """ - def copies_input(node: torch.fx.Node) -> bool: + def copied(node: torch.fx.Node) -> Optional[torch.fx.Node]: while (source := _copy_source(node)) is not None: if source not in partition.nodes: - return True + return source node = source - return False + return None - # Taking out a copy can make the copy it reads an output. - while leaving := [ - node - for node in partition.nodes - if copies_input(node) and any(u not in partition.nodes for u in node.users) - ]: + # Taking out a copy can make another copy leave. + while True: + outputs = { + node: copied(node) + for node in partition.nodes + if any(user not in partition.nodes for user in node.users) + } + returns = Counter(outputs.values()) + leaving = [ + node + for node, source in outputs.items() + if source is not None + and not ( + source in constants + and returns[source] == 1 + and all(user in partition.nodes for user in source.users) + and node.meta["val"].is_contiguous() + and node.meta["val"].dtype in dtypes + ) + ] + if not leaving: + return for node in leaving: - partition.remove_node(node) + # The copies it reads leave with it. A copy of a constant left inside + # could feed it while the partition also reads it, a cycle. + while (inner := _copy_source(node)) is not None and node in partition.nodes: + partition.remove_node(node) + node = inner class OpenvinoOperatorsSupport(OperatorSupportBase): @@ -294,8 +363,22 @@ def partition(self, exported_program: ExportedProgram) -> PartitionResult: partition_list = partitioner.propose_partitions() partition_tags = {} + constants = _constants(exported_program) + options = { + spec.key: spec.value.decode() for spec in self.delegation_spec.compile_specs + } + precision = _inference_precision( + options.get("device", "CPU"), options.get("inference_precision") + ) + # OpenVINO can round a float copy in any precision but f32, as an ARM + # CPU does in f16. + dtypes = { + dtype + for dtype in _CONSTANT_COPY_DTYPES + if precision == "f32" or not dtype.is_floating_point + } for partition in partition_list: - _remove_copies_of_inputs(partition) + _remove_copies_of_inputs(partition, constants, dtypes) for node in partition.nodes: tag = f"tag{partition.id}" node.meta["delegation_tag"] = tag diff --git a/backends/openvino/preprocess.py b/backends/openvino/preprocess.py index e11ac05aac1..1a17d120d3c 100644 --- a/backends/openvino/preprocess.py +++ b/backends/openvino/preprocess.py @@ -6,7 +6,7 @@ # mypy: disable-error-code=import-not-found -from typing import final, List +from typing import final, List, Optional import torch from executorch.backends.openvino._passes import ( @@ -22,7 +22,7 @@ ) from executorch.exir.backend.compile_spec_schema import CompileSpec from executorch.exir.passes.memory_format_ops_pass import DimOrderOpsRevertPass -from openvino import compile_model, opset13 # type: ignore[import-untyped] +from openvino import compile_model, opset13, opset16 # type: ignore[import-untyped] from openvino.frontend import FrontEndManager # type: ignore[import-untyped] from openvino.frontend.pytorch.fx_decoder import ( # type: ignore[import-untyped] TorchFXPythonDecoder, @@ -63,6 +63,31 @@ def _match_partition_inputs( ) +def _inference_precision(device: str, requested: Optional[str]) -> Optional[str]: + """The INFERENCE_PRECISION_HINT to pass, or None for the device's default.""" + return requested or ("f32" if device.startswith("CPU") else None) + + +def _copy_returned_inputs(model, device: str) -> None: + """Make the converted model compute each output that is one of its inputs. + + The converter turns copies like aten.clone into nothing, so such an output + is the input itself. The CPU plugin does not write it, and can overwrite + the input with the output buffer's old contents. OpenVINO keeps an + Identity between an input and an output, and the CPU plugin runs it as a + copy, while it can remove a Convert to the same type. On an ARM CPU at + f16, it rounds this copy of a float input, like its other float outputs. + Other devices are left as they are, since neither the problem nor Identity + was checked there. + """ + if not device.startswith("CPU"): + return + for result in model.get_results(): + source = result.input_value(0) + if source.get_node().get_type_name() == "Parameter": + result.input(0).replace_source_output(opset16.identity(source).output(0)) + + @final class OpenvinoBackend(BackendDetails): @@ -142,6 +167,7 @@ def preprocess( frontend = FrontEndManager().load_by_framework("pytorch") model = frontend.convert(frontend.load(decoder)) _match_partition_inputs(model, module, input_shapes, input_types) + _copy_returned_inputs(model, device) ppp = PrePostProcessor(model) # The converter takes an int input, like a SymInt size, as a scalar, but # the runtime passes it as an int64 tensor of shape [1]. @@ -150,7 +176,7 @@ def preprocess( ppp.input(i).preprocess().custom(lambda x: opset13.squeeze(x, 0).output(0)) # On ARM CPUs OpenVINO fixes the precision here, not when the runtime # imports the model, so this must follow the same rule as the runtime. - precision = requested_precision or ("f32" if device.startswith("CPU") else None) + precision = _inference_precision(device, requested_precision) config = {"INFERENCE_PRECISION_HINT": precision} if precision else {} compiled = compile_model(ppp.build(), device, config) model_bytes = compiled.export_model() diff --git a/backends/openvino/runtime/OpenvinoBackend.cpp b/backends/openvino/runtime/OpenvinoBackend.cpp index ff7c33aa4d9..15af84040bf 100644 --- a/backends/openvino/runtime/OpenvinoBackend.cpp +++ b/backends/openvino/runtime/OpenvinoBackend.cpp @@ -214,7 +214,7 @@ bool OpenvinoBackend::ensure_loaded() const { Error, "OpenVINO runtime not found (loading '%s' failed: %s). " "Ensure '%s' can be found (%s), or install with: " - "pip install \"openvino>=2025.1.0,<2027.0.0\"", + "pip install \"openvino>=2025.4.0,<2027.0.0\"", to_utf8(effective_path).c_str(), error.c_str(), to_utf8(kDefaultLibName).c_str(), diff --git a/backends/openvino/tests/ops/base_openvino_op_test.py b/backends/openvino/tests/ops/base_openvino_op_test.py index df921c4c4ac..1be9cb38fe3 100644 --- a/backends/openvino/tests/ops/base_openvino_op_test.py +++ b/backends/openvino/tests/ops/base_openvino_op_test.py @@ -1,7 +1,7 @@ import os import tempfile import unittest -from typing import Optional +from typing import Any, Optional, Sequence import executorch import torch @@ -10,6 +10,7 @@ from executorch.exir import ( EdgeCompileConfig, EdgeProgramManager, + ExecutorchProgramManager, to_edge_transform_and_lower, ) from executorch.exir.backend.backend_details import CompileSpec @@ -24,6 +25,27 @@ class BaseOpenvinoOpTest(unittest.TestCase): atol = 1e-3 rtol = 1e-3 + @staticmethod + def execute_program( + exec_prog: ExecutorchProgramManager, + all_inputs: Sequence[Sequence[torch.Tensor]], + ) -> list[Sequence[Any]]: + """Run each set of inputs, in order, on one loaded method, as an app + would.""" + # Load from a file: unlike a buffer, it really frees the delegate data + # when the backend frees it. + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp_dir: + pte_path = os.path.join(tmp_dir, "model.pte") + with open(pte_path, "wb") as f: + exec_prog.write_to_file(f) + program = Runtime.get().load_program(pte_path) + method = program.load_method("forward") + assert method is not None + all_outputs = [method.execute(inputs) for inputs in all_inputs] + # Windows cannot delete a mapped file, so release it first. + del method, program + return all_outputs + def execute_layer_test( self, module: torch.nn.Module, @@ -68,22 +90,8 @@ def execute_layer_test( # Execute the model and compare the outputs with the reference outputs if assert_output_equal: - # Later inputs run on the same loaded method, as an app would. all_inputs = (sample_inputs, *more_inputs) - - # Load from a file: unlike a buffer, it really frees the delegate - # data when the backend frees it. - with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp_dir: - pte_path = os.path.join(tmp_dir, "model.pte") - with open(pte_path, "wb") as f: - exec_prog.write_to_file(f) - program = Runtime.get().load_program(pte_path) - method = program.load_method("forward") - assert method is not None - all_outputs = [method.execute(inputs) for inputs in all_inputs] - # Windows cannot delete a mapped file, so release it first. - del method, program - + all_outputs = self.execute_program(exec_prog, all_inputs) for inputs, outputs in zip(all_inputs, all_outputs): # Execute the module in eager mode to calculate the reference outputs ref_output = module(*inputs) @@ -105,3 +113,5 @@ def execute_layer_test( ), msg=f"ref_output:\n{ref_output[i]}\n\ntest_output:\n{outputs[i]}", ) + + return exec_prog diff --git a/backends/openvino/tests/ops/test_copy.py b/backends/openvino/tests/ops/test_copy.py index c65b1d35272..586a7ec3422 100644 --- a/backends/openvino/tests/ops/test_copy.py +++ b/backends/openvino/tests/ops/test_copy.py @@ -1,8 +1,14 @@ +import itertools + import torch +from executorch.backends.openvino.preprocess import OpenvinoBackend from executorch.backends.openvino.tests.ops.base_openvino_op_test import ( BaseOpenvinoOpTest, ) -from executorch.exir import EdgeCompileConfig +from executorch.exir import EdgeCompileConfig, to_edge +from executorch.exir.backend.backend_api import to_backend +from executorch.exir.backend.backend_details import CompileSpec +from torch.export import Dim, export class TestCopyOperator(BaseOpenvinoOpTest): @@ -17,22 +23,28 @@ def forward(self, x): copy = self.copy(x) return copy, torch.sin(copy) + clone = "dim_order_ops::_clone_dim_order" copies = { - "clone": lambda x: x.clone(), - "full slice": lambda x: x[:, :], - "clone of a clone": lambda x: x.clone().clone(), + "clone": (lambda x: x.clone(), clone, None), + "full slice": (lambda x: x[:, :], "aten::alias_copy", None), + "clone of a clone": (lambda x: x.clone().clone(), clone, None), + # LLM exports turn dim order off, which makes a clone aten.clone. + "clone, dim order off": ( + lambda x: x.clone(), + "aten::clone", + EdgeCompileConfig(_skip_dim_order=True), + ), } inputs = (torch.randn(2, 3, 4, 5),) - for name, copy in copies.items(): + for name, (copy, operator, config) in copies.items(): with self.subTest(copy=name): - self.execute_layer_test(CopyAndSin(copy), inputs) - - # LLM exports turn dim order off, which makes a clone aten.clone. - self.execute_layer_test( - CopyAndSin(lambda x: x.clone()), - inputs, - compile_config=EdgeCompileConfig(_skip_dim_order=True), - ) + program = self.execute_layer_test( + CopyAndSin(copy), inputs, compile_config=config + ) + # On the CPU, preprocess would also give right values, so check + # where the copy runs. + operators = program.executorch_program.execution_plan[0].operators + self.assertIn(operator, [op.name for op in operators]) def test_copy_of_input_used_inside(self): class ChannelsLastMean(torch.nn.Module): @@ -42,3 +54,213 @@ def forward(self, x): # Run outside, the clone would hand OpenVINO a channels-last input, # which it reads as if it were contiguous. self.execute_layer_test(ChannelsLastMean(), (torch.randn(2, 3, 4, 5),)) + + def test_copy_of_constant_as_output(self): + if not self.device.startswith("CPU"): + self.skipTest("float copies stay only in float32, the CPU default") + + class CopyOfConstant(torch.nn.Module): + def __init__(self, kind, memory_format=None, dtype=torch.float32): + super().__init__() + self.memory_format = memory_format + if dtype.is_floating_point: + value = torch.randn(2, 3, 4, 5).to(dtype) + elif dtype == torch.bool: + value = torch.randn(2, 3, 4, 5) > 0 + else: + # float16 holds every integer only up to 2048, so these + # show a copy rounded through it, and x + constant stays + # in its range. + info = torch.iinfo(dtype) + low, high = max(info.min, -(2**15)), min(info.max, 2**15) + value = torch.randint(low, high, (2, 3, 4, 5), dtype=dtype) + if kind == "parameter": + self.constant = torch.nn.Parameter(value) + elif kind == "buffer": + self.register_buffer("constant", value) + else: + self.constant = value + + def forward(self, x): + copy = self.constant.clone(memory_format=self.memory_format) + return copy, x + self.constant + + cases = { + "parameter": (CopyOfConstant("parameter"), None), + "buffer": (CopyOfConstant("buffer"), None), + "lifted tensor": (CopyOfConstant("lifted tensor"), None), + # With dim order off this stays aten.clone, and the portable clone + # kernel rejects preserve_format. + "buffer, dim order off": ( + CopyOfConstant("buffer", torch.preserve_format), + EdgeCompileConfig(_skip_dim_order=True), + ), + # The precision changes only how OpenVINO computes floats. + "int32 buffer, f16 precision": ( + CopyOfConstant("buffer", dtype=torch.int32), + None, + CompileSpec("inference_precision", b"f16"), + ), + } + for dtype in ( + torch.bool, + torch.uint8, + torch.int8, + torch.int16, + torch.int32, + torch.float16, + torch.bfloat16, + ): + cases[f"{dtype} buffer"] = (CopyOfConstant("buffer", dtype=dtype), None) + for name, (module, config, *compile_specs) in cases.items(): + with self.subTest(case=name): + inputs = (torch.randn(2, 3, 4, 5),) + program = self.execute_layer_test( + module, + inputs, + more_inputs=((torch.randn(2, 3, 4, 5),),), + compile_specs=tuple(compile_specs), + compile_config=config, + ) + # OpenVINO gets a constant as its own data, not as an input, so + # the copy stays. Run outside, it would store the constant twice. + plan = program.executorch_program.execution_plan[0] + self.assertEqual(plan.operators, []) + # The tolerance above lets a copy rounded through float16 pass. + copy = self.execute_program(program, [inputs])[0][0] + torch.testing.assert_close(copy, module.constant, rtol=0, atol=0) + + def test_copy_of_constant_that_runs_outside(self): + class Buffered(torch.nn.Module): + def __init__(self, body, buffer): + super().__init__() + self.body = body + self.register_buffer("buffer", buffer) + + def forward(self, x): + return self.body(self.buffer, x) + + def copy_of_copy_and_sin(buffer, x): + copy = buffer.clone().clone(memory_format=torch.channels_last) + return copy, torch.sin(copy) + + def copy_and_copy_of_it(buffer, x): + copy = buffer.clone() + return copy, copy.clone(), buffer + x + + floats = torch.randn(2, 3, 4, 5) + cases = { + # OpenVINO writes every output in the default dim order. + "channels last": ( + lambda b, x: (b.clone(memory_format=torch.channels_last), b + x), + floats, + ), + # The inner copy must leave too, or export finds a cycle. + "copy of a copy, channels last": (copy_of_copy_and_sin, floats), + # The CPU plugin computes 64-bit types in 32 bits. + "int64": ( + lambda b, x: (b.clone(), x + 1), + torch.tensor([2**40, -(2**40)]), + ), + # The runtime cannot bind a uint16 output. + "uint16": ( + lambda b, x: (b.clone(), x + 1), + torch.tensor([0, 40000, 65535], dtype=torch.uint16), + ), + # OpenVINO's PyTorch frontend cannot return one constant twice. + "two copies": (copy_and_copy_of_it, floats), + # In a lower precision OpenVINO can round a float copy. + "float16 precision": ( + lambda b, x: (b.clone(), x + 1), + floats, + CompileSpec("inference_precision", b"f16"), + ), + # The partitioner does not take atan, so the CPU stores the buffer + # anyway, and a copy made there keeps it stored once. + "also read outside": (lambda b, x: (b.clone(), torch.atan(b) + x), floats), + } + for name, (body, buffer, *compile_specs) in cases.items(): + with self.subTest(case=name): + program = self.execute_layer_test( + Buffered(body, buffer), + (torch.randn(2, 3, 4, 5),), + compile_specs=tuple(compile_specs), + ) + operators = program.executorch_program.execution_plan[0].operators + names = [op.name for op in operators] + if name == "also read outside": + self.assertIn("aten::atan", names) + self.assertIn("dim_order_ops::_clone_dim_order", names) + + def test_copy_of_mutated_buffer_as_output(self): + class CopyOfMutatedBuffer(torch.nn.Module): + def __init__(self): + super().__init__() + self.register_buffer("buffer", torch.randn(2, 3, 4, 5)) + + def forward(self, x): + copy = self.buffer.clone() + self.buffer.add_(x) + return copy, torch.sin(copy) + + program = self.execute_layer_test( + CopyOfMutatedBuffer(), + (torch.randn(2, 3, 4, 5),), + assert_output_equal=False, + ) + # A mutated buffer is passed to the partition as an input, so its copy + # must run outside. + operators = program.executorch_program.execution_plan[0].operators + self.assertIn("dim_order_ops::_clone_dim_order", [op.name for op in operators]) + + # The program does not keep the starting value of a mutated buffer, so + # check each call against the one before it. + x1, x2 = torch.randn(2, 3, 4, 5), torch.randn(2, 3, 4, 5) + first, second = self.execute_program(program, ((x1,), (x2,))) + tolerance = {"atol": self.atol, "rtol": self.rtol} + torch.testing.assert_close(second[0], first[0] + x1, **tolerance) + for copy, sine in (first, second): + torch.testing.assert_close(sine, torch.sin(copy), **tolerance) + + def test_whole_program_returning_copy_of_input(self): + if not self.device.startswith("CPU"): + self.skipTest("preprocess adds the copy only for the CPU") + + class CopyAndReshape(torch.nn.Module): + def forward(self, x): + return x.clone(), x.reshape(-1) + + class Lowered(torch.nn.Module): + def __init__(self, lowered): + super().__init__() + self.lowered = lowered + + def forward(self, x): + return self.lowered(x) + + inputs = { + "float": lambda n: torch.randn(n, 3), + "int64": lambda n: torch.randint(-100, 100, (n, 3)), + "bool": lambda n: torch.randn(n, 3) > 0, + } + for (name, make), dynamic in itertools.product(inputs.items(), (False, True)): + dynamic_shapes = ({0: Dim("rows", min=1, max=4)},) if dynamic else None + with self.subTest(dtype=name, dynamic=dynamic): + x = make(4) + # Without a partitioner the copy cannot run on the CPU, so + # OpenVINO has to make it. + lowered = to_backend( + OpenvinoBackend.__name__, + to_edge( + export(CopyAndReshape(), (x,), dynamic_shapes=dynamic_shapes) + ).exported_program(), + [CompileSpec("device", self.device.encode())], + ) + program = to_edge( + export(Lowered(lowered), (x,), dynamic_shapes=dynamic_shapes) + ).to_executorch() + values = (x, make(2 if dynamic else 4)) + outputs = self.execute_program(program, [(v,) for v in values]) + for value, (copy, flat) in zip(values, outputs): + torch.testing.assert_close(copy, value) + torch.testing.assert_close(flat, value.reshape(-1)) diff --git a/docs/source/build-run-openvino.md b/docs/source/build-run-openvino.md index 2d842ed2bcd..1aecb1b48c0 100644 --- a/docs/source/build-run-openvino.md +++ b/docs/source/build-run-openvino.md @@ -43,9 +43,12 @@ The easiest way to use the OpenVINO backend is via the prebuilt pip wheel: pip install executorch[openvino] ``` -For now the extra installs no runtime on Python 3.15, or on Linux aarch64 with Python -3.14. OpenVINO publishes no build for Python 3.15, and its aarch64 builds for Python 3.14 -need glibc 2.35 or newer. +For now the extra installs no runtime on Python 3.15, where OpenVINO publishes no build. +On Linux aarch64, OpenVINO needs glibc 2.35 or newer, as on Ubuntu 22.04 or Raspberry Pi OS +Bookworm and later. On an older aarch64 system, `pip install executorch[openvino]` does not +fail: pip finds no OpenVINO 2025.4 or newer for that system, so it installs an older +ExecuTorch release instead. On such a system, install `executorch` without the extra, then +build OpenVINO from source as described below. The backend automatically discovers the OpenVINO C library from the pip-installed package — no environment variable setup is needed. @@ -61,14 +64,14 @@ If you need a custom build (different platform, custom configuration, etc.), fol Before you begin, ensure you have the OpenVINO runtime installed and configured on your system. For most users, we recommend installing it via pip: ```bash -pip install "openvino>=2025.1.0,<2027.0.0" +pip install "openvino>=2025.4.0,<2027.0.0" ``` Alternatively, you can build OpenVINO from source or follow the official [OpenVINO installation guide](https://docs.openvino.ai/2025/get-started/install-openvino.html) for platform-specific options: ```bash git clone https://github.com/openvinotoolkit/openvino.git -cd openvino && git checkout releases/2025/1 +cd openvino && git checkout releases/2026/4 git submodule update --init --recursive sudo ./install_build_dependencies.sh mkdir build && cd build @@ -124,7 +127,8 @@ float16, as measured on an Orin Nano. The value is OpenVINO's `INFERENCE_PRECISION_HINT`: `f32`, `f16` or `bf16`. An empty value means the default, and any other value fails at lowering. A CPU -without float16 support computes an `f16` program in float32. OpenVINO has no +without float16 support computes an `f16` program in bfloat16 if it has bfloat16 +units, as some x86 CPUs do, and in float32 otherwise. OpenVINO has no bfloat16 on ARM CPUs, so there `bf16` keeps the ARM default, float16 where the CPU supports it. Which x86 CPUs compute in bfloat16 is up to OpenVINO; see its [Precision Control](https://docs.openvino.ai/2026/openvino-workflow/running-inference/optimize-inference/precision-control.html) diff --git a/docs/source/using-executorch-cpp.md b/docs/source/using-executorch-cpp.md index 2391584196f..015b286243a 100644 --- a/docs/source/using-executorch-cpp.md +++ b/docs/source/using-executorch-cpp.md @@ -272,9 +272,11 @@ $env:OPENVINO_LIB_PATH = python -c "import openvino, os; print(os.path.join(os.p Without it the delegate still registers and the program still links, and the failure arrives later, when the model is loaded. -For now the openvino extra installs no runtime on Python 3.15, or on Linux aarch64 with -Python 3.14. OpenVINO publishes no build for Python 3.15, and its aarch64 builds for Python -3.14 need glibc 2.35 or newer. +For now the openvino extra installs no runtime on Python 3.15, where OpenVINO publishes no +build. On Linux aarch64, OpenVINO needs glibc 2.35 or newer, as on Ubuntu 22.04 or Raspberry +Pi OS Bookworm and later. On an older aarch64 system, such as RHEL 9 or Amazon Linux 2023, +`pip install executorch[openvino]` does not fail but installs an older ExecuTorch release; see +[OpenVINO Backend](build-run-openvino.md) for what to do there. The MLX delegate has a similar requirement. Its Metal kernels live in a separate `mlx.metallib` file, which MLX looks for next to whichever library holds MLX code. The wheel ships it beside the diff --git a/pyproject.toml b/pyproject.toml index 116552df1ed..f1d717bc1eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,10 +86,10 @@ ethos_u = [ "tosa-tools==2026.5.0", ] openvino = [ - # OpenVINO publishes no build for Python 3.15, and its Linux aarch64 builds for Python - # 3.14 need glibc 2.35 while the wheel needs only 2.28. Without these bounds pip fails to - # install the extra on Python 3.15 and on those older aarch64 systems. - "openvino>=2025.1.0,<2027.0.0; (platform_system == 'Linux' or (platform_system == 'Windows' and platform_machine == 'AMD64')) and python_version < '3.15' and (platform_machine != 'aarch64' or python_version < '3.14')", + # OpenVINO publishes no build for Python 3.15. Its Linux aarch64 builds need glibc 2.35 or + # newer while the wheel needs only 2.28, so on an older aarch64 system pip installs an older + # ExecuTorch for this extra instead; the docs say what to do there. + "openvino>=2025.4.0,<2027.0.0; (platform_system == 'Linux' or (platform_system == 'Windows' and platform_machine == 'AMD64')) and python_version < '3.15'", ] [project.urls] From b086d4459e2f707defac962ac88cd9ea4ef94b3d Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Wed, 4 Nov 2026 22:17:25 -0800 Subject: [PATCH 080/128] MLX: kill --isolate tests when the harness gets SIGTERM, SIGHUP or SIGQUIT, and say when index_copy_ clamps (#24641) ## What was wrong - `run_all_tests --isolate` runs each test in its own session, so a timeout can kill its process group. So a SIGTERM, SIGHUP or SIGQUIT sent to the harness's process group (a timeout wrapper, a closed terminal, `Ctrl-\`) does not reach the test. The harness died, but the test and its `op_test_runner` kept running, with no time limit on export. - A test killed by a signal failed without saying why. - The op support page did not say when `index_copy_` lowers to `index_put` and clamps. ## What this change does - On SIGTERM, SIGHUP or SIGQUIT during an isolated test, the harness kills the test's process group, flushes its output, then dies of the same signal. An ignored SIGHUP (`nohup`) stays ignored. - The harness prints "Killed by signal N" for a test killed by a signal. - The page says when `index_copy` and `index_copy_` clamp, and that `index_put` and `scatter_add` on 8-byte data such as `int64` run on the CPU, which raises on a bad index. ## What was tested The harness tests ran on an Apple Silicon Mac. Each test added for a fix fails when that fix is reverted. Also reworked: the timeout test fakes the timed wait, and the inspector test also checks success. --- backends/mlx/test/run_all_tests.py | 84 ++++++-- backends/mlx/test/test_run_all_tests.py | 231 ++++++++++++++++++--- docs/source/backends/mlx/mlx-op-support.md | 5 +- 3 files changed, 276 insertions(+), 44 deletions(-) diff --git a/backends/mlx/test/run_all_tests.py b/backends/mlx/test/run_all_tests.py index 144e5e1a8bb..2b721fe87a8 100644 --- a/backends/mlx/test/run_all_tests.py +++ b/backends/mlx/test/run_all_tests.py @@ -168,7 +168,8 @@ def _run_test_in_subprocess( Run a single test in an isolated subprocess. Each test gets its own Python interpreter so torch/MLX/Metal memory is - fully released between tests, preventing OOM on CI runners. + fully released between tests, preventing OOM on CI runners. Must be called + from the main thread, since it installs SIGTERM, SIGHUP and SIGQUIT handlers. Args: config_name: Name of the test configuration to run. @@ -189,27 +190,76 @@ def _run_test_in_subprocess( if verbose: cmd.append("--verbose") + proc = None + pending = [] + + # In its own session the child no longer gets a SIGTERM, SIGHUP or SIGQUIT + # sent to this process group, so kill its group, then die of the signal. + # Only for a signal that would end this process anyway, so an ignored + # SIGHUP (nohup) stays ignored. + def kill_test_and_die(signum, frame): + if proc is None: + # Popen may already have forked; finish once proc is known. + pending.append(signum) + return + _kill_process_group(proc.pid) + signal.signal(signum, signal.SIG_DFL) + _flush_and_die(signum) + + handled = [ + sig + for sig in (signal.SIGTERM, signal.SIGHUP, signal.SIGQUIT) + if signal.getsignal(sig) == signal.SIG_DFL + ] try: - # A session of its own, so a timeout also kills the op_test_runner the - # child started, not just the child. - proc = subprocess.Popen(cmd, start_new_session=True) - except Exception as e: - print(f"✗ FAILED: {config_name} - Subprocess error: {e}") - return False - try: - return proc.wait(timeout=timeout) == 0 + for sig in handled: + signal.signal(sig, kill_test_and_die) + try: + # A session of its own, so a timeout also kills the op_test_runner + # the child started, not just the child. + proc = subprocess.Popen(cmd, start_new_session=True) + except Exception as e: + print(f"✗ FAILED: {config_name} - Subprocess error: {e}") + return False + if pending: + kill_test_and_die(pending[0], None) + returncode = proc.wait(timeout=timeout) + if returncode < 0: + print(f"✗ FAILED: {config_name} - Killed by signal {-returncode}") + return returncode == 0 except subprocess.TimeoutExpired: print(f"✗ FAILED: {config_name} - Timeout after {timeout}s") return False finally: - # Also when the child has exited: if it crashed, its op_test_runner can - # still be running. ProcessLookupError means the group is gone; macOS - # raises PermissionError when only the child's zombie is left. - try: - os.killpg(proc.pid, signal.SIGKILL) - except (ProcessLookupError, PermissionError): - pass - proc.wait() + if proc is not None: + # Also when the child has exited: if it crashed, its op_test_runner + # can still be running. + _kill_process_group(proc.pid) + for sig in handled: + signal.signal(sig, signal.SIG_DFL) + if proc is not None: + proc.wait() + elif pending: + # Popen failed, but a signal came; still die of it. + _flush_and_die(pending[0]) + + +def _flush_and_die(signum: int) -> None: + # A flush can fail, for example when the pipe reader got the same signal + # and exited, and must not keep this process from dying of the signal. + try: + sys.stdout.flush() + finally: + os.kill(os.getpid(), signum) + + +def _kill_process_group(pgid: int) -> None: + # ProcessLookupError means the group is gone; macOS raises PermissionError + # when only the child's zombie is left. + try: + os.killpg(pgid, signal.SIGKILL) + except (ProcessLookupError, PermissionError): + pass def run_tests_parallel( diff --git a/backends/mlx/test/test_run_all_tests.py b/backends/mlx/test/test_run_all_tests.py index 7d0787bfd0a..331c7af1dae 100644 --- a/backends/mlx/test/test_run_all_tests.py +++ b/backends/mlx/test/test_run_all_tests.py @@ -6,6 +6,7 @@ """Tests for the MLX op test harness itself.""" +import io import os import signal import subprocess @@ -14,6 +15,7 @@ import time import unittest from pathlib import Path +from typing import Optional, Tuple from unittest import mock import torch @@ -26,6 +28,9 @@ from executorch.exir import to_edge +_HANDLED_SIGNALS = (signal.SIGTERM, signal.SIGHUP, signal.SIGQUIT) + + class RunAllTestsTest(unittest.TestCase): def test_isolated_run_reports_result(self): self.addCleanup(clean_test_outputs, ["sub_10"]) @@ -33,28 +38,178 @@ def test_isolated_run_reports_result(self): self.assertFalse(_run_test_in_subprocess("no_such_config")) def test_isolated_run_timeout_kills_grandchildren(self): - start = time.monotonic() - pid = self._run_child_with_grandchild("wait", timeout=10) - # Waiting out the sleep would mean the kill missed it. - self.assertLess(time.monotonic() - start, 30) + with tempfile.TemporaryDirectory() as tmp: + child, pid_file = _write_child(Path(tmp), "wait") + + timeouts = [] + + class TimesOut(subprocess.Popen): + # Times out as soon as the grandchild runs, without waiting. + def wait(self, timeout=None): + if timeout is None: + return super().wait() + timeouts.append(timeout) + deadline = time.monotonic() + 20 + while not _read(pid_file) and time.monotonic() < deadline: + time.sleep(0.05) + raise subprocess.TimeoutExpired(self.args, timeout) + + start = time.monotonic() + with mock.patch.object(sys, "executable", str(child)), mock.patch.object( + subprocess, "Popen", TimesOut + ), mock.patch("sys.stdout", new_callable=io.StringIO) as out: + self.assertFalse(_run_test_in_subprocess("sub_10", timeout=10)) + self.assertEqual(timeouts, [10]) + self.assertIn("Timeout after 10s", out.getvalue()) + # Waiting out the sleep would mean the kill missed it. + self.assertLess(time.monotonic() - start, 30) + pid = int(pid_file.read_text()) + self.addCleanup(_kill_if_alive, pid) self._assert_exits(pid) def test_isolated_run_kills_grandchildren_of_a_crashed_child(self): - self._assert_exits(self._run_child_with_grandchild("exit 1")) + # An ignored SIGHUP, as under nohup, must still be ignored afterwards. + self.addCleanup(signal.signal, signal.SIGHUP, signal.getsignal(signal.SIGHUP)) + signal.signal(signal.SIGHUP, signal.SIG_IGN) + handlers = [signal.getsignal(s) for s in _HANDLED_SIGNALS] + during = [] + + class RecordsHandlers(subprocess.Popen): + def __init__(self, *args, **kwargs): + during.extend(signal.getsignal(s) for s in _HANDLED_SIGNALS) + super().__init__(*args, **kwargs) - def _run_child_with_grandchild(self, ending: str, timeout: int = 60) -> int: with tempfile.TemporaryDirectory() as tmp: - # Stands in for the test process: it starts a long-running child, - # like op_test_runner, then waits for it or exits without it. - child = Path(tmp) / "child.sh" - pid_file = Path(tmp) / "grandchild.pid" - child.write_text(f"#!/bin/sh\nsleep 60 &\necho $! > {pid_file}\n{ending}\n") - child.chmod(0o755) - with mock.patch.object(sys, "executable", str(child)): - self.assertFalse(_run_test_in_subprocess("sub_10", timeout=timeout)) + child, pid_file = _write_child(Path(tmp), "kill -KILL $$") + with mock.patch.object(sys, "executable", str(child)), mock.patch.object( + subprocess, "Popen", RecordsHandlers + ), mock.patch("sys.stdout", new_callable=io.StringIO) as out: + self.assertFalse(_run_test_in_subprocess("sub_10")) pid = int(pid_file.read_text()) self.addCleanup(_kill_if_alive, pid) - return pid + # SIGTERM and SIGQUIT share the handler; the ignored SIGHUP is left alone. + sigterm, sighup, sigquit = during + self.assertTrue(callable(sigterm)) + self.assertIs(sigquit, sigterm) + self.assertEqual(sighup, signal.SIG_IGN) + self.assertIn("Killed by signal 9", out.getvalue()) + self.assertEqual([signal.getsignal(s) for s in _HANDLED_SIGNALS], handlers) + self._assert_exits(pid) + + def test_isolated_run_kills_grandchildren_on_sigterm_and_sighup(self): + # SIGQUIT takes the same path; sending it would leave a crash report. + for sig in (signal.SIGTERM, signal.SIGHUP): + with self.subTest(sig.name), tempfile.TemporaryDirectory() as tmp: + harness, pid = self._start_harness(Path(tmp), "wait") + harness.send_signal(sig) + out = harness.communicate(timeout=30)[0] + self.assertEqual(harness.returncode, -sig) + # Printed into a pipe before the signal, so it must be flushed. + self.assertIn("harness started", out) + self._assert_exits(pid) + + def test_isolated_run_kills_grandchildren_on_a_sigterm_during_popen(self): + with tempfile.TemporaryDirectory() as tmp: + pid_file = str(Path(tmp) / "grandchild.pid") + # The harness gets SIGTERM before Popen returns, once the test runs. + setup = ( + "import os, subprocess, time\n" + "class Popen(subprocess.Popen):\n" + " def __init__(self, *args, **kwargs):\n" + " super().__init__(*args, **kwargs)\n" + f" while not os.path.isfile({pid_file!r})" + f" or not os.path.getsize({pid_file!r}):\n" + " time.sleep(0.05)\n" + " os.kill(os.getpid(), signal.SIGTERM)\n" + "subprocess.Popen = Popen" + ) + harness, pid = self._start_harness(Path(tmp), "wait", setup) + self.assertEqual(harness.wait(timeout=30), -signal.SIGTERM) + self._assert_exits(pid) + + def test_isolated_run_dies_of_a_sigterm_that_arrives_when_popen_fails(self): + with tempfile.TemporaryDirectory() as tmp: + setup = ( + "import os, subprocess\n" + "class Popen(subprocess.Popen):\n" + " def __init__(self, *args, **kwargs):\n" + " os.kill(os.getpid(), signal.SIGTERM)\n" + " raise OSError('no memory to fork')\n" + "subprocess.Popen = Popen" + ) + harness = self._start_harness(Path(tmp), "wait", setup, wait=False)[0] + out = harness.communicate(timeout=60)[0] + self.assertEqual(harness.returncode, -signal.SIGTERM) + # Printed into a pipe just before dying, so it must be flushed. + self.assertIn("Subprocess error: no memory to fork", out) + + def test_isolated_run_dies_of_the_signal_when_the_flush_fails(self): + # As when the pipe reader got the same signal and exited first. + with tempfile.TemporaryDirectory() as tmp: + setup = ( + "class BrokenPipe:\n" + " def write(self, text):\n" + " return len(text)\n" + " def flush(self):\n" + " raise BrokenPipeError\n" + "sys.stdout = BrokenPipe()" + ) + harness, pid = self._start_harness(Path(tmp), "wait", setup) + harness.send_signal(signal.SIGTERM) + self.assertEqual(harness.wait(timeout=30), -signal.SIGTERM) + self._assert_exits(pid) + + def test_isolated_run_keeps_an_ignored_sighup_ignored(self): + # As under nohup: the test must finish instead of dying with the harness. + with tempfile.TemporaryDirectory() as tmp: + go = Path(tmp) / "go" + harness, _ = self._start_harness( + Path(tmp), + f"while [ ! -f {go} ]; do sleep 0.1; done", + "signal.signal(signal.SIGHUP, signal.SIG_IGN)", + ) + harness.send_signal(signal.SIGHUP) + go.touch() + # 0 means the test passed, so it was not killed. + self.assertEqual(harness.wait(timeout=60), 0) + + def _start_harness( + self, directory: Path, ending: str, setup: str = "", wait: bool = True + ) -> Tuple[subprocess.Popen, Optional[int]]: + child, pid_file = _write_child(directory, ending) + # Stands in for run_all_tests --isolate running one test. + harness = subprocess.Popen( + [ + sys.executable, + "-c", + "import signal, sys\n" + "from executorch.backends.mlx.test import run_all_tests\n" + # Not inherited from a test run under nohup. + "signal.signal(signal.SIGTERM, signal.SIG_DFL)\n" + "signal.signal(signal.SIGHUP, signal.SIG_DFL)\n" + "signal.signal(signal.SIGQUIT, signal.SIG_DFL)\n" + f"{setup}\n" + f"sys.executable = {str(child)!r}\n" + "print('harness started')\n" + "sys.exit(0 if run_all_tests._run_test_in_subprocess('sub_10') else 1)\n", + ], + stdout=subprocess.PIPE, + text=True, + ) + self.addCleanup(harness.stdout.close) + self.addCleanup(harness.kill) + if not wait: + return harness, None + deadline = time.monotonic() + 120 + while ( + not _read(pid_file) + and harness.poll() is None + and time.monotonic() < deadline + ): + time.sleep(0.1) + pid = int(pid_file.read_text()) + self.addCleanup(_kill_if_alive, pid) + return harness, pid def _assert_exits(self, pid: int) -> None: deadline = time.monotonic() + 10 @@ -103,22 +258,46 @@ def test_inspector_cli_fails_without_mlx_delegate(self): self.assertEqual(result.returncode, 1) self.assertIn("No MLX delegates", result.stderr) - def test_inspector_fails_on_unreadable_input(self): + def test_inspector_reports_whether_it_read_every_delegate(self): self.assertIs(pte_inspector.show_mlx_instructions(b"not a program"), False) - unreadable_delegate = { - "load": {"_load_mlx_payload": mock.Mock(side_effect=ValueError("bad"))}, - "parse": { - "_load_mlx_payload": mock.Mock(), - "parse_mlx_flatbuffer": mock.Mock(return_value={"error": "bad"}), - }, + one, two = [(0, {})], [(0, {}), (1, {})] + readable = mock.Mock(return_value=mock.Mock()) + cases = { + "readable": (one, readable, {}, True), + "load fails": (one, mock.Mock(side_effect=ValueError("bad")), {}, False), + "parse fails": (one, readable, {"error": "bad"}, False), + "first of two fails": ( + two, + mock.Mock(side_effect=[ValueError("bad"), mock.Mock()]), + {}, + False, + ), } - for name, patches in unreadable_delegate.items(): + for name, (delegates, load, graph, ok) in cases.items(): + load.reset_mock() with self.subTest(name), mock.patch.multiple( pte_inspector, - _find_mlx_delegates=mock.Mock(return_value=[(0, {})]), - **patches, + _find_mlx_delegates=mock.Mock(return_value=delegates), + _load_mlx_payload=load, + parse_mlx_flatbuffer=mock.Mock(return_value=graph), ): - self.assertIs(pte_inspector.show_mlx_instructions(b""), False) + self.assertIs(pte_inspector.show_mlx_instructions(b""), ok) + # A bad delegate must not stop the others from being read. + self.assertEqual(load.call_count, len(delegates)) + + +def _write_child(directory: Path, ending: str) -> Tuple[Path, Path]: + # Stands in for the test process: it starts a long-running child, like + # op_test_runner, then runs `ending`, for example waits for it. + child = directory / "child.sh" + pid_file = directory / "grandchild.pid" + child.write_text(f"#!/bin/sh\nsleep 60 &\necho $! > {pid_file}\n{ending}\n") + child.chmod(0o755) + return child, pid_file + + +def _read(path: Path) -> str: + return path.read_text() if path.exists() else "" def _write_program_without_mlx_delegate(directory: Path) -> Path: diff --git a/docs/source/backends/mlx/mlx-op-support.md b/docs/source/backends/mlx/mlx-op-support.md index fa1fe64681c..7ffadd99364 100644 --- a/docs/source/backends/mlx/mlx-op-support.md +++ b/docs/source/backends/mlx/mlx-op-support.md @@ -30,12 +30,15 @@ So a bad index never reads or writes memory outside the tensor, but it is still - `gather` returns values from the wrong place. - `index_put` overwrites real data at the first or last position, or adds to it with `accumulate=True`. For example, if an 8-slot cache is written as `cache[:, pos] = v` with `pos = torch.tensor([8])`, the write goes to slot 7. - `index_add_` lowers to `index_put` with `accumulate=True`, so it treats a bad index the same way, but adds instead of overwriting. +- `index_copy` and `index_copy_` also lower to `index_put` and overwrite the first or last position. The exception is a method where every `index_copy` or `index_copy_` is the last write to a mutable buffer or input. There they raise an error on an index outside `[-size, size - 1]`. Other ops that take indices behave differently, for example: -- `index_copy_` into a mutable buffer, and reading `x[i]` with a Python int, raise an error on an index outside `[-size, size - 1]`. +- Reading `x[i]` with a Python int raises an error on an index outside `[-size, size - 1]`. - `embedding`, `index_select`, `scatter_add`, and reading `x[idx]` with an index tensor do not check or clamp. An index outside `[-size, size - 1]` there can read or write memory outside the tensor. +When the tensor that `index_put` or `scatter_add` writes has an 8-byte dtype, such as `int64` or `float64`, the MLX backend does not run it. It runs on the CPU instead, which raises an error on a bad index. This also applies to `index_add_`, and to `index_copy` and `index_copy_` where they lower to `index_put`. + To find bad indices, run the model in eager PyTorch on the CPU first, where they raise. Keep run-time indices in range too, for example by stopping generation before a cache position reaches the cache size. On an empty axis there is nothing to clamp to: `gather` with a non-empty index raises, and `index_put` returns the input unchanged. From 26b4f5dd49dccf7ed87328631b81cb1cb8689575 Mon Sep 17 00:00:00 2001 From: Edmund Shieh <119344573+edmundshieh@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:48:54 -0600 Subject: [PATCH 081/128] Don't log an error when there are no CUDA backend symbols to promote (#23895) ### Summary `load_library` re-opens the library that holds the CUDA backend with `RTLD_NOLOAD | RTLD_GLOBAL` to make its symbols global. When the backend is linked into the executable, as in a statically linked runner, `dladdr` names the executable, and `dlopen` finds no library to re-open and sets no error. So every first load logged "Failed to promote symbols: (null)", although the executable's symbols are already global and there is nothing to promote. The error is now logged only when `dlopen` reports one; `dlerror()` is cleared before the call so a stale error is not reported. This PR was authored with AI (Claude Code). ### Test plan `backends/cuda/runtime/platform/platform.cpp` compiles (`g++ -std=c++17 -fsyntax-only -Wall -Wextra`). The `dlopen` behavior the change relies on, checked with a small program that calls `dlopen(, RTLD_NOW | RTLD_GLOBAL | RTLD_NOLOAD)` on glibc 2.39 (Jetson AGX Thor, Ubuntu 24.04): ``` dli_fname=./noload handle=(nil) dlerror=(null) ``` cc @cccclai @nil-is-all @Gasoonjia @digantdesai --- backends/cuda/runtime/platform/platform.cpp | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/backends/cuda/runtime/platform/platform.cpp b/backends/cuda/runtime/platform/platform.cpp index fe06b890b1d..defc283229e 100644 --- a/backends/cuda/runtime/platform/platform.cpp +++ b/backends/cuda/runtime/platform/platform.cpp @@ -86,11 +86,24 @@ executorch::runtime::Result load_library( Dl_info info; // Get info about a symbol we know exists in this backend library if (dladdr((void*)&load_library, &info) && info.dli_fname) { - // Re-open with RTLD_GLOBAL | RTLD_NOLOAD to promote symbols + // Re-open with RTLD_GLOBAL | RTLD_NOLOAD to promote symbols. Clear any + // earlier error first, so dlerror() below reports only this call's. + dlerror(); void* handle = dlopen(info.dli_fname, RTLD_NOW | RTLD_GLOBAL | RTLD_NOLOAD); if (!handle) { - ET_LOG(Error, "Failed to promote symbols: %s", dlerror()); + const char* error = dlerror(); + if (error != nullptr) { + ET_LOG(Error, "Failed to promote symbols: %s", error); + } else { + // dlopen found no library to re-open and set no error. That is the + // case when this backend is linked into the executable, whose + // symbols are global already. + ET_LOG( + Debug, + "Not promoting symbols: %s is not loaded as a shared library", + info.dli_fname); + } } else { // Close the handle after successful promotion dlclose(handle); From 27e6120028779da374b18e72954601ab9e328dd2 Mon Sep 17 00:00:00 2001 From: Jiri Ocenasek Date: Wed, 4 Nov 2026 10:22:59 +0100 Subject: [PATCH 082/128] NXP backend: Pin the MCUXPresso repo version for the frequent build test, implement weekly unpinned test Co-authored-by: AxelNoun <150222552+AxelNoun@users.noreply.github.com> --- .github/workflows/_test_nxp_mcuxpresso.yml | 44 +++++++++++++++++++ .github/workflows/nxp-weekly.yml | 25 +++++++++++ .github/workflows/pull.yml | 15 +------ .../test_build_from_scratch.sh | 5 ++- 4 files changed, 74 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/_test_nxp_mcuxpresso.yml create mode 100644 .github/workflows/nxp-weekly.yml diff --git a/.github/workflows/_test_nxp_mcuxpresso.yml b/.github/workflows/_test_nxp_mcuxpresso.yml new file mode 100644 index 00000000000..488d5a240aa --- /dev/null +++ b/.github/workflows/_test_nxp_mcuxpresso.yml @@ -0,0 +1,44 @@ +name: Test NXP MCUXpresso example + +permissions: + id-token: write + contents: read + +on: + workflow_call: + inputs: + mcuxsdk-manifest-rev: + description: 'MCUXpresso SDK manifest revision to build against. Defaults to the pinned revision baked into the test script.' + required: false + type: string + default: '' + timeout: + description: 'Job timeout in minutes' + required: false + type: number + default: 90 + +jobs: + docker-image: + name: Resolve CI docker image + uses: ./.github/workflows/_docker-image.yml + + run: + needs: docker-image + uses: pytorch/test-infra/.github/workflows/linux_job_v3.yml@main + permissions: + id-token: write + contents: read + with: + runner: mt-l-x86iavx512-8-64 + docker-image: ${{ needs.docker-image.outputs.docker-registry }}/ci-image:executorch-ubuntu-22.04-arm-sdk-${{ needs.docker-image.outputs.ci-docker-hash }} + submodules: 'recursive' + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + timeout: ${{ inputs.timeout }} + script: | + # The generic Linux job chooses to use base env, not the one setup by the image + CONDA_ENV=$(conda env list --json | jq -r ".envs | .[-1]") + conda activate "${CONDA_ENV}" + + export MCUXSDK_MANIFEST_REV="${{ inputs.mcuxsdk-manifest-rev }}" + ./examples/nxp/mcuxpresso/imxrt700/executorch_cifarnet/test_build_from_scratch.sh diff --git a/.github/workflows/nxp-weekly.yml b/.github/workflows/nxp-weekly.yml new file mode 100644 index 00000000000..7e7ab167e39 --- /dev/null +++ b/.github/workflows/nxp-weekly.yml @@ -0,0 +1,25 @@ +name: nxp-weekly + +on: + pull_request: + paths: + - .github/workflows/nxp-weekly.yml + schedule: + # Run once a week, Saturday at 12:00 UTC (runs on default branch: main) + - cron: '0 12 * * 6' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + mcuxpresso-test: + name: mcuxpresso-test + uses: ./.github/workflows/_test_nxp_mcuxpresso.yml + permissions: + id-token: write + contents: read + with: + # The weekly test uses the unpinned manifest revision + mcuxsdk-manifest-rev: main diff --git a/.github/workflows/pull.yml b/.github/workflows/pull.yml index 947e1970eee..1584c349880 100644 --- a/.github/workflows/pull.yml +++ b/.github/workflows/pull.yml @@ -1944,21 +1944,8 @@ jobs: fi nxp-mcuxpresso-test: - needs: docker-image name: nxp-mcuxpresso-test - uses: pytorch/test-infra/.github/workflows/linux_job_v3.yml@main + uses: ./.github/workflows/_test_nxp_mcuxpresso.yml permissions: id-token: write contents: read - with: - runner: mt-l-x86iavx512-8-64 - docker-image: ${{ needs.docker-image.outputs.docker-registry }}/ci-image:executorch-ubuntu-22.04-arm-sdk-${{ needs.docker-image.outputs.ci-docker-hash }} - submodules: 'recursive' - ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} - timeout: 90 - script: | - # The generic Linux job chooses to use base env, not the one setup by the image - CONDA_ENV=$(conda env list --json | jq -r ".envs | .[-1]") - conda activate "${CONDA_ENV}" - - ./examples/nxp/mcuxpresso/imxrt700/executorch_cifarnet/test_build_from_scratch.sh diff --git a/examples/nxp/mcuxpresso/imxrt700/executorch_cifarnet/test_build_from_scratch.sh b/examples/nxp/mcuxpresso/imxrt700/executorch_cifarnet/test_build_from_scratch.sh index dbc311aee1f..1fb1c4c9682 100755 --- a/examples/nxp/mcuxpresso/imxrt700/executorch_cifarnet/test_build_from_scratch.sh +++ b/examples/nxp/mcuxpresso/imxrt700/executorch_cifarnet/test_build_from_scratch.sh @@ -6,6 +6,7 @@ set -ue ARM_TOOLCHAIN_URL="${ARM_TOOLCHAIN_URL:-https://developer.arm.com/-/media/Files/downloads/gnu/15.2.rel1/binrel/arm-gnu-toolchain-15.2.rel1-x86_64-arm-none-eabi.tar.xz}" +MCUXSDK_MANIFEST_REV="${MCUXSDK_MANIFEST_REV:-v26.12.00-pvw1}" # Get arm gcc echo Downloading ARM GCC toolchain @@ -43,7 +44,9 @@ export NEUTRON_LIB_DIR=${NEUTRON_LIB_DIR}/target/imxrt700/rt700/cm33 echo Downloading MCUXpresso SDK if [ ! -d mcuxpresso-sdk ]; then pip install west - west init -m https://github.com/nxp-mcuxpresso/mcuxsdk-manifests.git mcuxpresso-sdk + # Pin the manifest revision if needed + west init -m https://github.com/nxp-mcuxpresso/mcuxsdk-manifests.git \ + --mr "${MCUXSDK_MANIFEST_REV}" mcuxpresso-sdk pushd mcuxpresso-sdk west update_board --set board mimxrt700evk popd From baea4c05b3828c25e401954861e5efb39f6ae55a Mon Sep 17 00:00:00 2001 From: Zingo Andersen Date: Thu, 5 Nov 2026 09:09:26 +0100 Subject: [PATCH 083/128] Preserve logical element order when lowering view_copy (#24152) ### Summary Only replace view_copy with a shared-storage view when its input is contiguous. Retain mutation safety checks and copy channels-last inputs in logical order in the portable kernel, keeping the existing memcpy fast path for contiguous inputs. ### Test plan Cover layout and view-removal configurations, safe contiguous views, and the Cortex-M patch-token regression. Authored with assistance from OpenAI Codex. Signed-off-by: Zingo Andersen --- backends/cortex_m/test/build_test_runner.sh | 1 + backends/cortex_m/test/targets.bzl | 3 +- backends/cortex_m/test/test_pass_manager.py | 50 +++++++++++- .../replace_view_copy_with_view_pass.py | 3 + exir/tests/targets.bzl | 6 ++ exir/tests/test_remove_view_copy.py | 80 ++++++++++++++++++- kernels/portable/cpu/op_view_copy.cpp | 26 ++++-- kernels/test/op_view_copy_test.cpp | 34 ++++++++ .../kernels/portable/op_registration_util.bzl | 6 ++ 9 files changed, 200 insertions(+), 9 deletions(-) diff --git a/backends/cortex_m/test/build_test_runner.sh b/backends/cortex_m/test/build_test_runner.sh index 665472234c1..707f825e660 100755 --- a/backends/cortex_m/test/build_test_runner.sh +++ b/backends/cortex_m/test/build_test_runner.sh @@ -48,6 +48,7 @@ ops_list=( aten::convolution.out aten::max_pool2d_with_indices.out dim_order_ops::_clone_dim_order.out + aten::view_copy.out aten::cat.out aten::full.out aten::ge.Tensor_out diff --git a/backends/cortex_m/test/targets.bzl b/backends/cortex_m/test/targets.bzl index ac48ee03869..fdb552ddf70 100644 --- a/backends/cortex_m/test/targets.bzl +++ b/backends/cortex_m/test/targets.bzl @@ -135,6 +135,7 @@ def define_common_targets(is_fbcode = False): "//executorch/backends/cortex_m/passes:cortex_passes", "//executorch/backends/cortex_m/quantizer:quantizer", "//executorch/exir:lib", + "//executorch/exir:memory", "//executorch/exir:pass_base", "//executorch/exir/_serialize:lib", "//executorch/exir/dialects:lib", @@ -142,8 +143,6 @@ def define_common_targets(is_fbcode = False): ], ) - - python_pytest( name = "test_fuse_conv_padding", srcs = ["test_fuse_conv_padding.py"], diff --git a/backends/cortex_m/test/test_pass_manager.py b/backends/cortex_m/test/test_pass_manager.py index 627901b030f..cf9931c75c4 100644 --- a/backends/cortex_m/test/test_pass_manager.py +++ b/backends/cortex_m/test/test_pass_manager.py @@ -1,5 +1,6 @@ # Copyright (c) Meta Platforms, Inc. and affiliates. # All rights reserved. +# Copyright 2026 Arm Limited and/or its affiliates. # # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. @@ -12,7 +13,7 @@ from executorch.backends.cortex_m.passes.cortex_m_pass_manager import CortexMPassManager from executorch.backends.cortex_m.quantizer.quantizer import CortexMQuantizer from executorch.backends.cortex_m.target_config import CortexM, CortexMTargetConfig -from executorch.exir import to_edge, to_edge_transform_and_lower +from executorch.exir import memory, to_edge, to_edge_transform_and_lower from executorch.exir._serialize._program import deserialize_pte_binary from executorch.exir.dialects._ops import ops as exir_ops from executorch.exir.pass_base import ExportPass @@ -166,3 +167,50 @@ def call_operator(self, op, args, kwargs, meta): assert any(node.op == "get_attr" for node in program.graph.nodes) assert not program.graph_signature.buffers torch.testing.assert_close(program.module()(*inputs), torch.tensor([2.0, 4.0])) + + +@pytest.mark.parametrize( + "channels_last,use_explicit_layout", + [(False, False), (True, False), (False, True)], +) +def test_flatten_preserves_patch_tokens(channels_last, use_explicit_layout): + class PatchTokens(torch.nn.Module): + def __init__(self): + super().__init__() + self.conv = torch.nn.Conv2d(3, 4, 2, stride=2) + + def forward(self, x): + return self.conv(x).flatten(2).transpose(1, 2) + + torch.manual_seed(0) + model = PatchTokens().eval() + inputs = (torch.randn(1, 3, 6, 6),) + if channels_last: + model = model.to(memory_format=torch.channels_last) + inputs = (inputs[0].to(memory_format=torch.channels_last),) + exported, expected, scale = _quantize(model, inputs, use_explicit_layout) + edge = to_edge_transform_and_lower( + exported, + compile_config=cortex_m_edge_compile_config(), + transform_passes=CortexMPassManager(use_explicit_layout=use_explicit_layout), + ) + torch.testing.assert_close( + edge.exported_program().module()(*inputs), expected, rtol=0, atol=2 * scale + ) + program = edge.to_executorch().exported_program() + views = [node for node in program.graph.nodes if node.target == memory.view] + view_copies = [ + node + for node in program.graph.nodes + if node.target == torch.ops.aten.view_copy.out + ] + assert views or view_copies + for node in views: + assert ( + node.args[0].meta["val"].is_contiguous() + ), "Runtime views must not alias channels-last storage as NCHW" + if use_explicit_layout: + assert not any( + node.target == torch.ops.dim_order_ops._to_dim_order_copy.out + for node in program.graph.nodes + ) diff --git a/exir/passes/replace_view_copy_with_view_pass.py b/exir/passes/replace_view_copy_with_view_pass.py index a6fd449e3db..20dd4ca22c3 100644 --- a/exir/passes/replace_view_copy_with_view_pass.py +++ b/exir/passes/replace_view_copy_with_view_pass.py @@ -434,6 +434,7 @@ def call(self, graph_module: torch.fx.GraphModule) -> PassResult: This should be run after the NormalizeViewCopyBasePass. + Only contiguous inputs can share storage with the contiguous view spec. During memory planning, view nodes share the same storage as their base. """ @@ -449,6 +450,7 @@ def call(self, graph_module: torch.fx.GraphModule) -> PassResult: if ( _is_view_copy(node) and all(u.op != "output" for u in node.users) + and node.args[0].meta["val"].is_contiguous() and is_copy_to_view_safe(node) ): base, _ = node.args @@ -495,6 +497,7 @@ def ensures(self, graph_module: torch.fx.GraphModule) -> None: assert not ( _is_view_copy(node) and all(u.op != "output" for u in node.users) + and node.args[0].meta["val"].is_contiguous() and is_copy_to_view_safe(node) ) if node.op == "call_function" and node.target == _VIEW_OP: diff --git a/exir/tests/targets.bzl b/exir/tests/targets.bzl index 59ae7b904b2..2ad0a4ebb17 100644 --- a/exir/tests/targets.bzl +++ b/exir/tests/targets.bzl @@ -1,3 +1,8 @@ +# Copyright 2026 Arm Limited and/or its affiliates. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + load("@fbcode_macros//build_defs:cpp_library.bzl", "cpp_library") load("@fbsource//xplat/executorch/build:runtime_wrapper.bzl", "runtime") load("@fbcode_macros//build_defs:python_unittest.bzl", "python_unittest") @@ -474,6 +479,7 @@ def define_common_targets(is_fbcode = False): "//executorch/exir/capture:config", "//executorch/exir/dialects:lib", "//executorch/exir/passes:lib", + "//executorch/extension/pybindings:portable_lib", # @manual ], ) diff --git a/exir/tests/test_remove_view_copy.py b/exir/tests/test_remove_view_copy.py index 78b4e0531e0..8e9e954d6d7 100644 --- a/exir/tests/test_remove_view_copy.py +++ b/exir/tests/test_remove_view_copy.py @@ -1,15 +1,17 @@ # Copyright (c) Meta Platforms, Inc. and affiliates. # All rights reserved. +# Copyright 2026 Arm Limited and/or its affiliates. # # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. import copy import unittest +from itertools import product import torch import torch.nn as nn -from executorch.exir import memory, to_edge +from executorch.exir import EdgeCompileConfig, memory, to_edge from executorch.exir.capture._config import ExecutorchBackendConfig from executorch.exir.dialects._ops import ops from executorch.exir.passes import MemoryPlanningPass, ToOutVarPass @@ -22,6 +24,10 @@ ) from executorch.exir.passes.spec_prop_pass import SpecPropPass +from executorch.extension.pybindings.portable_lib import ( # @manual + _load_for_executorch_from_buffer, +) + class TestModel1(nn.Module): __test__ = False @@ -63,6 +69,78 @@ def _run_view_and_reinplace_passes( ReplaceViewCopyWithViewPass()(graph_module) return graph_module + def test_flatten_layout_runtime(self) -> None: + class Flatten(nn.Module): + def __init__(self, internal_channels_last): + super().__init__() + self.internal_channels_last = internal_channels_last + + def forward(self, x): + if self.internal_channels_last: + x = x.clone(memory_format=torch.channels_last) + return x.flatten(2).transpose(1, 2) + + for layout, skip_dim_order, remove_view_copy in product( + ("contiguous", "channels_last", "singleton", "internal"), + (False, True), + (False, True), + ): + # Explicit layout conversions require dim-order operators. + if layout == "internal" and skip_dim_order: + continue + with self.subTest( + layout=layout, + skip_dim_order=skip_dim_order, + remove_view_copy=remove_view_copy, + ): + channels = 1 if layout == "singleton" else 3 + x = torch.arange(16 * channels, dtype=torch.float32).reshape( + 2, channels, 2, 4 + ) + if layout in ("channels_last", "singleton"): + x = x.contiguous(memory_format=torch.channels_last) + model = Flatten(layout == "internal").eval() + edge = to_edge( + torch.export.export(model, (x,), strict=True), + compile_config=EdgeCompileConfig(_skip_dim_order=skip_dim_order), + ) + program = edge.to_executorch( + config=ExecutorchBackendConfig(remove_view_copy=remove_view_copy) + ) + targets = [ + node.target for node in program.exported_program().graph.nodes + ] + uses_view = remove_view_copy and layout in ("contiguous", "singleton") + self.assertEqual(memory.view in targets, uses_view) + self.assertEqual(torch.ops.aten.view_copy.out in targets, not uses_view) + if skip_dim_order and layout != "internal": + self.assertFalse(any("dim_order" in str(t) for t in targets)) + self.assertNotIn(torch.ops.aten._to_copy.out, targets) + runtime = _load_for_executorch_from_buffer(program.buffer) + actual = runtime.run_method("forward", (x,))[0] + torch.testing.assert_close(actual, model(x), rtol=0, atol=0) + + def test_view_replacement_preserves_input_layout(self) -> None: + class Flatten(nn.Module): + def forward(self, x): + return x.flatten(2).transpose(1, 2) + + for channels in (1, 3): + for channels_last in (False, True): + with self.subTest(channels=channels, channels_last=channels_last): + x = torch.arange(8 * channels, dtype=torch.float32).reshape( + 1, channels, 2, 4 + ) + if channels_last: + x = x.contiguous(memory_format=torch.channels_last) + graph = self._run_view_and_reinplace_passes(Flatten(), (x,)) + targets = [node.target for node in graph.graph.nodes] + if channels_last and channels > 1: + self.assertNotIn(memory.view, targets) + self.assertIn(ops.edge.aten.view_copy.default, targets) + else: + self.assertIn(memory.view, targets) + def test_disable(self) -> None: model = TestModel1() model.eval() diff --git a/kernels/portable/cpu/op_view_copy.cpp b/kernels/portable/cpu/op_view_copy.cpp index cf173882445..bcf335df4dd 100644 --- a/kernels/portable/cpu/op_view_copy.cpp +++ b/kernels/portable/cpu/op_view_copy.cpp @@ -1,6 +1,7 @@ /* * Copyright (c) Meta Platforms, Inc. and affiliates. * All rights reserved. + * Copyright 2026 Arm Limited and/or its affiliates. * * This source code is licensed under the BSD-style license found in the * LICENSE file in the root directory of this source tree. @@ -9,6 +10,7 @@ #include #include +#include #include #include @@ -44,16 +46,30 @@ Tensor& view_copy_out( out, "Failed to resize output tensor."); - ET_KERNEL_CHECK( - ctx, tensors_have_same_dim_order(self, out), InvalidArgument, out); - - ET_KERNEL_CHECK(ctx, tensor_is_default_dim_order(self), InvalidArgument, out); + ET_KERNEL_CHECK(ctx, tensor_is_default_dim_order(out), InvalidArgument, out); ET_KERNEL_CHECK( ctx, check_view_copy_args(self, size_int64_t, out), InvalidArgument, out); - if (self.nbytes() > 0) { + if (self.nbytes() == 0) { + return out; + } + if (is_contiguous_dim_order( + self.dim_order().data(), self.dim_order().size())) { memcpy(out.mutable_data_ptr(), self.const_data_ptr(), self.nbytes()); + } else { + const auto* input_data = self.const_data_ptr(); + auto* output_data = out.mutable_data_ptr(); + const auto element_size = self.element_size(); + // Iterate the input's logical shape; the output may have a different rank. + for (const auto [output_index, input_index] : + BroadcastIndexesRange<1, /*support_noncontiguous_input_tensors=*/true>( + self, self)) { + memcpy( + output_data + output_index * element_size, + input_data + input_index * element_size, + element_size); + } } return out; } diff --git a/kernels/test/op_view_copy_test.cpp b/kernels/test/op_view_copy_test.cpp index 4990f1b1a77..99ea7996da6 100644 --- a/kernels/test/op_view_copy_test.cpp +++ b/kernels/test/op_view_copy_test.cpp @@ -1,6 +1,7 @@ /* * Copyright (c) Meta Platforms, Inc. and affiliates. * All rights reserved. + * Copyright 2026 Arm Limited and/or its affiliates. * * This source code is licensed under the BSD-style license found in the * LICENSE file in the root directory of this source tree. @@ -146,6 +147,39 @@ TEST_F(OpViewTest, EmptyInputSupported) { #undef TEST_ENTRY } +TEST_F(OpViewTest, ChannelsLastInputPreservesLogicalOrder) { + TensorFactory tf; + for (const auto& shape : + std::vector>{{1, 2, 1, 3}, {1, 2, 1, 1, 3}}) { + Tensor input = tf.make_channels_last(shape, {0, 3, 1, 4, 2, 5}); + Tensor out = tf.zeros({2, 3}); + Tensor expected = tf.make({2, 3}, {0, 1, 2, 3, 4, 5}); + const int64_t size[] = {-1, 3}; + op_view_copy_out(input, size, out); + EXPECT_TENSOR_EQ(out, expected); + } +} + +TEST_F(OpViewTest, EmptyChannelsLastInput) { + TensorFactory tf; + Tensor input = tf.make_channels_last({1, 2, 0, 3}, {}); + Tensor out = tf.zeros({2, 0}); + const int64_t size[] = {2, 0}; + op_view_copy_out(input, size, out); + EXPECT_TENSOR_EQ(out, tf.zeros({2, 0})); +} + +TEST_F(OpViewTest, ChannelsLastInputResizesOutput) { + TensorFactory tf; + Tensor input = tf.make_channels_last({1, 2, 1, 3}, {0, 3, 1, 4, 2, 5}); + Tensor out = + tf.zeros({4, 4}, torch::executor::TensorShapeDynamism::DYNAMIC_BOUND); + Tensor expected = tf.make({2, 3}, {0, 1, 2, 3, 4, 5}); + const int64_t size[] = {2, 3}; + op_view_copy_out(input, size, out); + EXPECT_TENSOR_EQ(out, expected); +} + TEST_F(OpViewTest, InputOutputMismatchedSizesDie) { TensorFactory tf; std::vector size_in = {3, 1, 1, 2}; diff --git a/shim_et/xplat/executorch/kernels/portable/op_registration_util.bzl b/shim_et/xplat/executorch/kernels/portable/op_registration_util.bzl index 4ab7f461770..78ccd5783de 100644 --- a/shim_et/xplat/executorch/kernels/portable/op_registration_util.bzl +++ b/shim_et/xplat/executorch/kernels/portable/op_registration_util.bzl @@ -1,3 +1,8 @@ +# Copyright 2026 Arm Limited and/or its affiliates. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + load("@fbsource//xplat/executorch/build:runtime_wrapper.bzl", "get_aten_mode_options", "is_xplat", "runtime") load("@fbsource//xplat/executorch/build:selects.bzl", "selects") @@ -1399,6 +1404,7 @@ ATEN_OPS = ( op_target( name = "op_view_copy", deps = [ + "//executorch/kernels/portable/cpu/util:broadcast_indexes_range", "//executorch/kernels/portable/cpu/util:copy_ops_util", ], ), From 0b8cfc2876c09bb38cc749900370cb310099b90d Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Thu, 5 Nov 2026 00:14:54 -0800 Subject: [PATCH 084/128] Docs: more C++ troubleshooting and a GPU input and output example for the pip package (#24633) ## Problem Building a C++ app against the ExecuTorch pip package works well, but some common mistakes give errors that "Using ExecuTorch with C++" does not explain: - Finding the package with `os.path.dirname(executorch.__file__)` fails with a `TypeError`. The `executorch` package has no `__init__.py`, so `__file__` is `None`. - A model whose weights are in a separate `.ptd` file fails to load when only the `.pte` is passed. The CUDA backend reports `... is missing from named data`. - A GPU model exported with `alloc_graph_output=False` fails on its first run when the program does not give it output buffers. The C++ guide already shows the right way for the first two, and the Module guide and the CUDA backend guide cover the third. But the C++ guide never names these errors, so someone who hits one cannot tell which fix applies. ## Change In `docs/source/using-executorch-cpp.md`: - A new CUDA subsection says that a model can keep its inputs and outputs on the GPU. It links the CUDA backend guide for the export side, and shows the C++ side in a few lines: GPU tensors made with `from_blob`, and `set_output` before the first run. - "When something does not work" gets an entry for each error above, with its fix. ## Testing On Linux x86_64 with an NVIDIA A100, in fresh virtual environments with `executorch==1.6.0` and `torch==2.15.0`, installed from PyPI and from the CPU index: - The CPU walkthrough on this page (export, program, CMake, build and run) prints `11 22 33 44` with both installs. The `os.path.dirname(executorch.__file__)` form fails as described. - With the PyPI install, the CUDA export from this page, loaded with `Module("model.pte", "aoti_cuda_blob.ptd")`, prints `11 22 33 44`. - A CUDA model with weights fails to load without its `.ptd` file with the message quoted above, and loads with it. Saving only `program.buffer` writes no `.ptd` and fails the same way. - The new snippet, pasted unchanged into a small program that allocates the GPU buffers and prints the result, runs a model exported with GPU inputs and outputs and `alloc_graph_output=False`, and prints `11 22 33 44`. - A model exported with only the input copies skipped writes its output into a host buffer given with `set_output`. - A minimal Sphinx build of the page shows no new warnings, and both new links resolve. --- docs/source/using-executorch-cpp.md | 67 ++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/docs/source/using-executorch-cpp.md b/docs/source/using-executorch-cpp.md index 015b286243a..e17364cd4c7 100644 --- a/docs/source/using-executorch-cpp.md +++ b/docs/source/using-executorch-cpp.md @@ -337,11 +337,24 @@ way, because they sit next to the runtime library. - `find_package` could not find executorch: the `-DCMAKE_PREFIX_PATH=...` argument is missing or points somewhere else. Run the `python -c` line on its own and check the folder exists. +- `os.path.dirname(executorch.__file__)` fails with a `TypeError`: the `executorch` package has no + `__init__.py`, so its `__file__` is `None`. Use `executorch.__path__[0]` instead, as the command + above does. - The program builds but fails to load the model: the path is relative, so run it from the folder containing the `.pte` file. - A missing operator at run time: add a kernel component, usually `executorch::kernels_optimized`. - The model fails to load complaining about a backend: link the backend it was exported for. +- The model fails to load because data is missing, for example the CUDA backend reports + `... is missing from named data`: the model expects its weights in a separate `.ptd` file. Pass that + file as the second argument, for example `Module module("model.pte", "aoti_cuda_blob.ptd");`. If + the export saved only `program.buffer`, no `.ptd` was written. Save the program with + `save_pte_program`, as [the CUDA section](#running-on-a-gpu-with-the-cuda-package) does. +- A GPU model fails on its first run because an output has no buffer: it was exported with + `alloc_graph_output=False`, so it has no memory of its own for its outputs. Give each output a + buffer with `set_output` before the first run: GPU memory if the export skipped the output copy + (`skip_d2h_for_method_outputs=True`), host memory otherwise. See + [Keeping inputs and outputs on the GPU](#keeping-inputs-and-outputs-on-the-gpu). - `executorch::runtime` is not a target: imported targets need CMake 3.28 or newer. On an older CMake the package still works, but you name variables instead of targets, and you have to pass on the definitions and the C++ standard requirement yourself: @@ -477,8 +490,8 @@ save_pte_program(program, "model", ".") `save_pte_program` is used instead of writing the buffer by hand because the CUDA backend puts its model weights in a **separate data file** next to `model.pte`. The compiled GPU code stays -inside `model.pte`. Writing only the program file loses the weights, so the model then fails when it -runs. +inside `model.pte`. For a model with weights, writing only the program file leaves them out, and the +model then fails to load. The backend chooses that file's name, so check what was written: @@ -515,6 +528,56 @@ outside an ARM package. Note that `torch.cuda.get_arch_list()` is not the right PyTorch builds for a wider set at the bottom than these packages do, so a GPU can appear in that list and still not be supported. +#### Keeping inputs and outputs on the GPU + +A model can be exported to keep its inputs and outputs on the GPU, so the runtime does not copy +them between CPU and GPU memory on every run. +[The CUDA backend guide](backends/cuda/cuda-overview.md#activation-memory-who-owns-the-gpu-copies) +shows how. Such a model takes GPU tensors. If it was also exported with `alloc_graph_output=False`, +as the guide recommends, it has no memory of its own for its outputs, and without a GPU buffer for +each output the run fails. Give each output one with `set_output`, once, before the first run. Its +second argument, the output's index, defaults to 0, which is what the example below uses: + +```cpp +using namespace executorch::aten; +using namespace executorch::extension; +using namespace executorch::runtime; + +// a, b and sum point to GPU memory, for example from cudaMalloc. +auto x = from_blob(a, {2, 2}, ScalarType::Float, DeviceType::CUDA); +auto y = from_blob(b, {2, 2}, ScalarType::Float, DeviceType::CUDA); +auto out = from_blob(sum, {2, 2}, ScalarType::Float, DeviceType::CUDA); + +// Every run then writes its result to sum. +if (module.set_output(out) != Error::Ok) { + return 1; +} +const auto result = module.forward({x, y}); +if (!result.ok()) { + return 1; +} +``` + +The result is in `sum`, in GPU memory. Copy it to the host, for example with `cudaMemcpy`, before +you read it on the CPU. + +`forward` can return before the GPU has finished. The model runs on `cudaStreamPerThread`, the +default stream of the thread that calls `forward`, and a `cudaMemcpy` on that thread waits for it. +Before `forward`, call `cudaStreamSynchronize` on each stream of yours that wrote the inputs or still +reads the previous result. After `forward`, call `cudaStreamSynchronize(cudaStreamPerThread)` on the +same thread before a stream of yours reads the result or overwrites the inputs. To run the model on +your own stream instead, see "Choosing the CUDA stream" under +[Performance Options in the TensorRT guide](backends/tensorrt/tensorrt-overview.md#performance-options). +It works for a model that uses only the CUDA backend too, and it lists the limits. + +Calling the CUDA runtime yourself, for example `cudaMalloc` and `cudaMemcpy`, needs the CUDA Toolkit +when you build. Add these lines at the end of `CMakeLists.txt`: + +```cmake +find_package(CUDAToolkit REQUIRED) +target_link_libraries(app PRIVATE CUDA::cudart) +``` + #### CUDA on Windows The Windows CUDA package runs CUDA programs; it cannot lower them. Lowering compiles the model with From e25bd5a23031651dc3f58575ddc302204d144147 Mon Sep 17 00:00:00 2001 From: pytorchbot Date: Thu, 5 Nov 2026 01:13:13 -0800 Subject: [PATCH 085/128] [executorch][muse] Share the Muse Glimmer embeddings engine across backends (#24652) This PR was created by the merge bot to help merge the original PR into the main branch. ghstack PR number: https://github.com/pytorch/executorch/pull/24062 by @Gasoonjia ^ Please use this as the source of truth for the PR details, comments, and reviews ghstack PR base: https://github.com/pytorch/executorch/tree/gh/Gasoonjia/85/base ghstack PR head: https://github.com/pytorch/executorch/tree/gh/Gasoonjia/85/head Merge bot PR base: https://github.com/pytorch/executorch/tree/main Merge bot PR head: https://github.com/pytorch/executorch/tree/gh/Gasoonjia/85/orig Differential Revision: [D125987290](https://our.internmc.facebook.com/intern/diff/D125987290/) @diff-train-skip-merge Co-authored-by: gasoonjia --- examples/models/muse-glimmer/CMakeLists.txt | 2 +- examples/models/muse-glimmer/runtime/BUCK | 39 +++- .../muse-glimmer/runtime/mlx_executor.cpp | 184 ++--------------- .../muse-glimmer/runtime/mlx_executor.h | 5 +- .../muse-glimmer/runtime/model_embeddings.cpp | 186 ++++++++++++++++++ .../muse-glimmer/runtime/model_embeddings.h | 72 +++++++ examples/models/muse-glimmer/vision/BUCK | 16 ++ extension/llm/serving/BUCK | 2 +- 8 files changed, 326 insertions(+), 180 deletions(-) create mode 100644 examples/models/muse-glimmer/runtime/model_embeddings.cpp create mode 100644 examples/models/muse-glimmer/runtime/model_embeddings.h create mode 100644 examples/models/muse-glimmer/vision/BUCK diff --git a/examples/models/muse-glimmer/CMakeLists.txt b/examples/models/muse-glimmer/CMakeLists.txt index abcd30994d9..640b48f9d50 100644 --- a/examples/models/muse-glimmer/CMakeLists.txt +++ b/examples/models/muse-glimmer/CMakeLists.txt @@ -270,7 +270,7 @@ if(MUSE_GLIMMER_BUILD_BATCHING OR MUSE_GLIMMER_BUILD_BATCHING_TESTS) add_library( muse_glimmer_batching_runtime STATIC runtime/backend_factory.cpp ${_batching_executor} - runtime/runners/batching_bootstrap.cpp + runtime/model_embeddings.cpp runtime/runners/batching_bootstrap.cpp ) target_link_libraries( muse_glimmer_batching_runtime diff --git a/examples/models/muse-glimmer/runtime/BUCK b/examples/models/muse-glimmer/runtime/BUCK index 1cdb774e0d8..b29793fa4ab 100644 --- a/examples/models/muse-glimmer/runtime/BUCK +++ b/examples/models/muse-glimmer/runtime/BUCK @@ -6,13 +6,15 @@ oncall("executorch") fbcode_target( _kind = runtime.cxx_library, name = "cuda_dflash_executor", - srcs = ["cuda_dflash_executor.cpp"], + srcs = [ + "cuda_dflash_executor.cpp", + "model_embeddings.cpp", + ], header_namespace = "executorch/examples/models/muse-glimmer/runtime", exported_headers = [ "backend_factory.h", "cuda_dflash_executor.h", - "engine/muse_glimmer_vision_runtime.h", - "prompt_preparation.h", + "model_embeddings.h", ], visibility = ["PUBLIC"], deps = [ @@ -21,6 +23,7 @@ fbcode_target( "//executorch/runtime/backend:interface", ], exported_deps = [ + ":muse_glimmer_batching_preparation", "fbsource//third-party/nlohmann-json:nlohmann-json", "//executorch/backends/cuda/batching:cuda_executor", "//executorch/extension/llm/batching:batching", @@ -32,3 +35,33 @@ fbcode_target( ], external_deps = [("cuda", None, "cuda-lazy"), "nlohmann_json"], ) + +fbcode_target( + _kind = runtime.cxx_library, + name = "muse_glimmer_batching_preparation", + srcs = [ + "embedding_materializer.cpp", + "engine/muse_glimmer_vision_runtime.cpp", + "prompt_preparation.cpp", + ], + header_namespace = "executorch/examples/models/muse-glimmer/runtime", + exported_headers = [ + "embedding_materializer.h", + "engine/muse_glimmer_vision_runtime.h", + "prompt_preparation.h", + ], + preprocessor_flags = ["-DEXECUTORCH_BUILD_CUDA"], + visibility = ["PUBLIC"], + deps = [ + "//executorch/examples/models/muse-glimmer/vision:preprocess", + "//executorch/runtime/platform:platform", + "//pytorch/tokenizers:headers", + ], + exported_deps = [ + "fbsource//third-party/nlohmann-json:nlohmann-json", + "//executorch/extension/llm/serving:serving_runtime", + "//executorch/extension/module:module", + "//executorch/extension/tensor:tensor", + ], + external_deps = [("cuda", None, "cuda-lazy")], +) diff --git a/examples/models/muse-glimmer/runtime/mlx_executor.cpp b/examples/models/muse-glimmer/runtime/mlx_executor.cpp index c90a5156937..ce0e2b3f568 100644 --- a/examples/models/muse-glimmer/runtime/mlx_executor.cpp +++ b/examples/models/muse-glimmer/runtime/mlx_executor.cpp @@ -8,8 +8,8 @@ #include #include -#include #include +#include #include #include #include @@ -25,118 +25,15 @@ namespace executorch::extension::llm { namespace { +using muse_glimmer_abi::check_constant_method; +using muse_glimmer_abi::dense_tensor; +using muse_glimmer_abi::tensor_abi; using runtime::Error; constexpr const char* kDecoder = "forward_from_embeddings"; constexpr const char* kBackend = "MLXBackend"; -bool tensor_abi( - const runtime::TensorInfo& info, - aten::ScalarType dtype, - std::initializer_list shape) { - if (info.scalar_type() != dtype || info.sizes().size() != shape.size() || - info.dim_order().size() != shape.size()) { - return false; - } - size_t bytes = runtime::elementSize(dtype); - size_t dim = 0; - for (int64_t size : shape) { - if (size <= 0 || size != info.sizes()[dim] || - info.dim_order()[dim] != dim || - static_cast(size) > - std::numeric_limits::max() / bytes) { - return false; - } - bytes *= static_cast(size); - ++dim; - } - return info.nbytes() == bytes; -} - -bool dense_tensor(const aten::Tensor& tensor) { - int64_t stride = 1; - for (int dim = tensor.dim() - 1; dim >= 0; --dim) { - if (tensor.size(dim) <= 0 || - (tensor.size(dim) != 1 && tensor.strides()[dim] != stride) || - stride > std::numeric_limits::max() / tensor.size(dim)) { - return false; - } - stride *= tensor.size(dim); - } - return tensor.device().is_cpu() && tensor.const_data_ptr() != nullptr; -} - Error check_mlx_backends(const runtime::MethodMeta& meta) { - if (meta.num_backends() == 0) { - return Error::InvalidProgram; - } - for (size_t i = 0; i < meta.num_backends(); ++i) { - ET_ASSIGN_OR_RETURN(name, meta.get_backend_name(i)); - if (std::string(name) != kBackend) { - return Error::NotSupported; - } - } - return Error::Ok; -} - -// Metadata can be read during create(), but no backend may load off-engine. -Error check_constant_method(Module& module, const char* name) { - ET_ASSIGN_OR_RETURN(meta, module.method_meta(name)); - if (meta.num_inputs() != 0 || meta.num_outputs() != 1 || - meta.num_backends() != 0 || meta.num_instructions() != 0) { - ET_LOG(Error, "Muse Glimmer metadata %s must be constant", name); - return Error::InvalidProgram; - } - return Error::Ok; -} - -runtime::Result check_vision_abi( - Module& module, - int32_t hidden, - aten::ScalarType dtype, - int max_patches) { - ET_CHECK_OK_OR_RETURN_ERROR( - check_constant_method(module, "get_max_vision_patches")); - ET_ASSIGN_OR_RETURN( - published_patches, - detail::read_required_positive_int(module, "get_max_vision_patches")); - if (published_patches < 4 || - published_patches > std::numeric_limits::max()) { - return Error::InvalidProgram; - } - // Export bounds patches in complete 2x2 downsampling groups, but publishes - // the original limit (which need not be divisible by four). - const int64_t patches = (published_patches / 4) * 4; - ET_ASSIGN_OR_RETURN(meta, module.method_meta("vision_encoder")); - if (meta.num_inputs() != 9 || meta.num_outputs() != 1) { - return Error::InvalidProgram; - } - ET_CHECK_OK_OR_RETURN_ERROR(check_mlx_backends(meta)); - // Host preprocessing's nine-input ABI; inspect it without loading vision. - for (size_t i = 0; i < 9; ++i) { - ET_ASSIGN_OR_RETURN(input, meta.input_tensor_meta(i)); - bool valid = false; - if (i == 0) { - valid = tensor_abi(input, aten::ScalarType::Float, {1, patches, 1176}); - } else if (i == 1) { - // Positional interpolation is always BF16, even for an FP16 decoder. - valid = tensor_abi(input, aten::ScalarType::BFloat16, {1, patches, 1536}); - } else if (i == 2 || i == 3) { - valid = tensor_abi(input, aten::ScalarType::Float, {patches, 48}); - } else if (i == 6 || i == 7) { - valid = - tensor_abi(input, aten::ScalarType::Bool, {1, 1, patches, patches}); - } else { - valid = tensor_abi(input, aten::ScalarType::Long, {patches}); - } - if (!valid) { - return Error::InvalidProgram; - } - } - ET_ASSIGN_OR_RETURN(output, meta.output_tensor_meta(0)); - if (!tensor_abi(output, dtype, {1, patches / 4, hidden})) { - return Error::InvalidProgram; - } - return std::min(patches, max_patches) / 4; + return muse_glimmer_abi::check_backends(meta, kBackend); } uint64_t prediction_seed(uint64_t seed, batching::Position position) { @@ -172,67 +69,6 @@ struct MuseGlimmerMLXExecutor::DFlash { } }; -struct MuseGlimmerMLXExecutor::EngineEmbeddings { - std::shared_ptr spec; - MuseGlimmerMaterializer materializer; - std::string pos_embed_path; - std::mutex vision_mutex; - std::unique_ptr vision; - - EngineEmbeddings( - std::shared_ptr spec, - std::string path) - : spec(std::move(spec)), - materializer(this->spec), - pos_embed_path(std::move(path)) {} - - Error initialize(Module& module) { - ET_CHECK_OK_OR_RETURN_ERROR(module.load_method("embed_text")); - if (spec->has_vision) { - ET_CHECK_OK_OR_RETURN_ERROR(module.load_method("vision_encoder")); - MuseGlimmerVisionRuntimeConfig config; - config.module = &module; - config.execution_mutex = &vision_mutex; - config.pos_embed_path = pos_embed_path; - config.activation_dtype = spec->activation_dtype; - config.expected_hidden_dim = spec->hidden_dim; - config.max_image_tokens = spec->max_soft_tokens; - config.max_encoded_bytes = spec->image_limits.max_encoded_bytes; - config.max_image_dimension = spec->image_limits.max_image_dimension; - config.max_image_pixels = spec->image_limits.max_image_pixels; - vision = std::make_unique(std::move(config)); - } - return Error::Ok; - } - - runtime::Result materialize( - Module& module, - const std::vector& inputs) { - return materializer.materialize( - inputs, - [&](const MuseGlimmerRGBImage& image) - -> runtime::Result { - return vision->prepare_decoded_image( - image.rgb.data(), image.width, image.height); - }, - [&](const std::vector& tokens) -> runtime::Result { - auto input = make_tensor_ptr( - {1, static_cast(tokens.size())}, tokens); - ET_ASSIGN_OR_RETURN( - outputs, - module.execute( - "embed_text", std::vector{input})); - if (outputs.size() != 1 || !outputs[0].isTensor() || - !dense_tensor(outputs[0].toTensor())) { - return Error::InvalidProgram; - } - // The materializer copies these rows before another method can reuse - // the module's shared output arena. - return make_tensor_ptr(outputs[0].toTensor()); - }); - } -}; - MuseGlimmerMLXExecutor::MuseGlimmerMLXExecutor( std::unique_ptr module, std::shared_ptr cache, @@ -243,8 +79,9 @@ MuseGlimmerMLXExecutor::MuseGlimmerMLXExecutor( module_(std::move(module)), ctl_(cache->as()), spec_(std::move(spec)), - embeddings_( - std::make_unique(spec_, std::move(pos_embed_path))), + embeddings_(std::make_unique( + spec_, + std::move(pos_embed_path))), max_sessions_(max_sessions) {} MuseGlimmerMLXExecutor::~MuseGlimmerMLXExecutor() = default; @@ -364,7 +201,8 @@ runtime::Result MuseGlimmerMLXExecutor::create( if (has_vision) { ET_ASSIGN_OR_RETURN( soft_tokens, - check_vision_abi(*module, hidden, dtype, config.max_vision_patches)); + muse_glimmer_abi::check_vision_abi( + *module, hidden, dtype, config.max_vision_patches, kBackend)); max_soft_tokens = soft_tokens; } const int session_capacity = config.max_session_tokens @@ -711,7 +549,7 @@ void MuseGlimmerMLXExecutor::set_sampling( bool MuseGlimmerMLXExecutor::accepts( const batching::PreparedInput& input) const { - return embeddings_->materializer.accepts(input); + return embeddings_->accepts(input); } runtime::Result diff --git a/examples/models/muse-glimmer/runtime/mlx_executor.h b/examples/models/muse-glimmer/runtime/mlx_executor.h index efa0c74cbda..6b0a47a7a00 100644 --- a/examples/models/muse-glimmer/runtime/mlx_executor.h +++ b/examples/models/muse-glimmer/runtime/mlx_executor.h @@ -21,6 +21,8 @@ namespace executorch::extension::llm { +class MuseGlimmerModelEmbeddings; + class MuseGlimmerMLXExecutor final : public batching::Executor { public: static runtime::Result create( @@ -43,7 +45,6 @@ class MuseGlimmerMLXExecutor final : public batching::Executor { override; private: - struct EngineEmbeddings; struct DFlash; using Probabilities = std::vector>; struct SessionState { @@ -94,7 +95,7 @@ class MuseGlimmerMLXExecutor final : public batching::Executor { std::unique_ptr module_; cache::BatchControl* const ctl_; const std::shared_ptr spec_; - std::unique_ptr embeddings_; + std::unique_ptr embeddings_; const int max_sessions_; bool initialization_attempted_ = false; bool initialized_ = false; diff --git a/examples/models/muse-glimmer/runtime/model_embeddings.cpp b/examples/models/muse-glimmer/runtime/model_embeddings.cpp new file mode 100644 index 00000000000..4a5501c6692 --- /dev/null +++ b/examples/models/muse-glimmer/runtime/model_embeddings.cpp @@ -0,0 +1,186 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ +#include + +#include +#include + +#include +#include + +namespace executorch::extension::llm { +using runtime::Error; + +namespace muse_glimmer_abi { +bool tensor_abi( + const runtime::TensorInfo& info, + aten::ScalarType dtype, + std::initializer_list shape) { + if (info.scalar_type() != dtype || info.sizes().size() != shape.size() || + info.dim_order().size() != shape.size()) { + return false; + } + size_t bytes = runtime::elementSize(dtype); + size_t dim = 0; + for (int64_t size : shape) { + if (size <= 0 || size != info.sizes()[dim] || + info.dim_order()[dim] != dim || + static_cast(size) > + std::numeric_limits::max() / bytes) { + return false; + } + bytes *= static_cast(size); + ++dim; + } + return info.nbytes() == bytes; +} + +bool dense_tensor(const aten::Tensor& tensor) { + int64_t stride = 1; + for (int64_t dim = tensor.dim() - 1; dim >= 0; --dim) { + if (tensor.size(dim) <= 0 || + (tensor.size(dim) != 1 && tensor.strides()[dim] != stride) || + stride > std::numeric_limits::max() / tensor.size(dim)) { + return false; + } + stride *= tensor.size(dim); + } + return tensor.device().is_cpu() && tensor.const_data_ptr() != nullptr; +} + +Error check_backends(const runtime::MethodMeta& meta, const char* backend) { + if (meta.num_backends() == 0) { + return Error::InvalidProgram; + } + for (size_t i = 0; i < meta.num_backends(); ++i) { + ET_ASSIGN_OR_RETURN(name, meta.get_backend_name(i)); + if (std::string(name) != backend) { + return Error::NotSupported; + } + } + return Error::Ok; +} + +Error check_constant_method(Module& module, const char* name) { + ET_ASSIGN_OR_RETURN(meta, module.method_meta(name)); + if (meta.num_inputs() != 0 || meta.num_outputs() != 1 || + meta.num_backends() != 0 || meta.num_instructions() != 0) { + ET_LOG(Error, "Muse Glimmer metadata %s must be constant", name); + return Error::InvalidProgram; + } + return Error::Ok; +} + +runtime::Result check_vision_abi( + Module& module, + int32_t hidden, + aten::ScalarType dtype, + int max_patches, + const char* backend) { + ET_CHECK_OK_OR_RETURN_ERROR( + check_constant_method(module, "get_max_vision_patches")); + ET_ASSIGN_OR_RETURN( + published_patches, + detail::read_required_positive_int(module, "get_max_vision_patches")); + if (published_patches < 4 || + published_patches > std::numeric_limits::max()) { + return Error::InvalidProgram; + } + // Export bounds patches in complete 2x2 downsampling groups, but publishes + // the original limit (which need not be divisible by four). + const int64_t patches = (published_patches / 4) * 4; + ET_ASSIGN_OR_RETURN(meta, module.method_meta("vision_encoder")); + if (meta.num_inputs() != 9 || meta.num_outputs() != 1) { + return Error::InvalidProgram; + } + ET_CHECK_OK_OR_RETURN_ERROR(check_backends(meta, backend)); + // Host preprocessing's nine-input ABI; inspect it without loading vision. + for (size_t i = 0; i < 9; ++i) { + ET_ASSIGN_OR_RETURN(input, meta.input_tensor_meta(i)); + bool valid = false; + if (i == 0) { + valid = tensor_abi(input, aten::ScalarType::Float, {1, patches, 1176}); + } else if (i == 1) { + // Positional interpolation is always BF16, even for an FP16 decoder. + valid = tensor_abi(input, aten::ScalarType::BFloat16, {1, patches, 1536}); + } else if (i == 2 || i == 3) { + valid = tensor_abi(input, aten::ScalarType::Float, {patches, 48}); + } else if (i == 6 || i == 7) { + valid = + tensor_abi(input, aten::ScalarType::Bool, {1, 1, patches, patches}); + } else { + valid = tensor_abi(input, aten::ScalarType::Long, {patches}); + } + if (!valid) { + return Error::InvalidProgram; + } + } + ET_ASSIGN_OR_RETURN(output, meta.output_tensor_meta(0)); + if (!tensor_abi(output, dtype, {1, patches / 4, hidden})) { + return Error::InvalidProgram; + } + return std::min(patches, max_patches) / 4; +} +} // namespace muse_glimmer_abi + +MuseGlimmerModelEmbeddings::MuseGlimmerModelEmbeddings( + std::shared_ptr spec, + std::string pos_embed_path) + : spec_(std::move(spec)), + materializer_(spec_), + pos_embed_path_(std::move(pos_embed_path)) {} + +Error MuseGlimmerModelEmbeddings::initialize(Module& module) { + ET_CHECK_OK_OR_RETURN_ERROR(module.load_method("embed_text")); + if (spec_->has_vision) { + ET_CHECK_OK_OR_RETURN_ERROR(module.load_method("vision_encoder")); + MuseGlimmerVisionRuntimeConfig config; + config.module = &module; + config.execution_mutex = &vision_mutex_; + config.pos_embed_path = pos_embed_path_; + config.activation_dtype = spec_->activation_dtype; + config.expected_hidden_dim = spec_->hidden_dim; + config.max_image_tokens = spec_->max_soft_tokens; + config.max_encoded_bytes = spec_->image_limits.max_encoded_bytes; + config.max_image_dimension = spec_->image_limits.max_image_dimension; + config.max_image_pixels = spec_->image_limits.max_image_pixels; + vision_ = std::make_unique(std::move(config)); + } + return Error::Ok; +} + +runtime::Result MuseGlimmerModelEmbeddings::materialize( + Module& module, + const std::vector& inputs) { + return materializer_.materialize( + inputs, + [&](const MuseGlimmerRGBImage& image) + -> runtime::Result { + if (!vision_) { + return Error::InvalidState; + } + return vision_->prepare_decoded_image( + image.rgb.data(), image.width, image.height); + }, + [&](const std::vector& tokens) -> runtime::Result { + auto input = make_tensor_ptr( + {1, static_cast(tokens.size())}, tokens); + ET_ASSIGN_OR_RETURN( + outputs, + module.execute("embed_text", std::vector{input})); + if (outputs.size() != 1 || !outputs[0].isTensor() || + !muse_glimmer_abi::dense_tensor(outputs[0].toTensor())) { + return Error::InvalidProgram; + } + // The materializer copies these rows before another method can reuse + // the module's shared output arena. + return make_tensor_ptr(outputs[0].toTensor()); + }); +} + +} // namespace executorch::extension::llm diff --git a/examples/models/muse-glimmer/runtime/model_embeddings.h b/examples/models/muse-glimmer/runtime/model_embeddings.h new file mode 100644 index 00000000000..79fdd4e8982 --- /dev/null +++ b/examples/models/muse-glimmer/runtime/model_embeddings.h @@ -0,0 +1,72 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace executorch::extension::llm { + +namespace muse_glimmer_abi { +// Exact dtype, contiguous shape and byte size of a method input or output. +bool tensor_abi( + const runtime::TensorInfo& info, + aten::ScalarType dtype, + std::initializer_list shape); +// Non-empty, contiguous and host-resident. +bool dense_tensor(const aten::Tensor& tensor); +// Every delegate of the method is `backend`. +runtime::Error check_backends( + const runtime::MethodMeta& meta, + const char* backend); +// Metadata can be read during create(), but no backend may load off-engine. +runtime::Error check_constant_method(Module& module, const char* name); +// Checks vision_encoder's host-preprocessing ABI without loading it. Returns +// the soft tokens one image may contribute. +runtime::Result check_vision_abi( + Module& module, + int32_t hidden, + aten::ScalarType dtype, + int max_patches, + const char* backend); +} // namespace muse_glimmer_abi + +// The program's embed_text and, when the spec has vision, vision_encoder: +// prepared slices become one host [1, rows, hidden] tensor of decoder inputs. +class MuseGlimmerModelEmbeddings final { + public: + MuseGlimmerModelEmbeddings( + std::shared_ptr spec, + std::string pos_embed_path); + + runtime::Error initialize(Module& module); + bool accepts(const batching::PreparedInput& input) const { + return materializer_.accepts(input); + } + // Text rows run through embed_text in one call; image rows reuse each image's + // encoding across the slices of one prompt. + runtime::Result materialize( + Module& module, + const std::vector& inputs); + + private: + std::shared_ptr spec_; + MuseGlimmerMaterializer materializer_; + std::string pos_embed_path_; + std::mutex vision_mutex_; + std::unique_ptr vision_; +}; + +} // namespace executorch::extension::llm diff --git a/examples/models/muse-glimmer/vision/BUCK b/examples/models/muse-glimmer/vision/BUCK new file mode 100644 index 00000000000..0e0697ba2a8 --- /dev/null +++ b/examples/models/muse-glimmer/vision/BUCK @@ -0,0 +1,16 @@ +load("@fbcode_macros//build_defs:build_file_migration.bzl", "fbcode_target") +load("@fbsource//xplat/executorch/build:runtime_wrapper.bzl", "runtime") + +oncall("executorch") + +fbcode_target( + _kind = runtime.cxx_library, + name = "preprocess", + exported_headers = ["preprocess.h"], + header_namespace = "executorch/examples/models/muse-glimmer/vision", + visibility = ["PUBLIC"], + exported_deps = [ + "fbsource//third-party/stb:stb-no-symbols", + "//executorch/extension/tensor:tensor", + ], +) diff --git a/extension/llm/serving/BUCK b/extension/llm/serving/BUCK index fec0c13afa7..cbb01df6734 100644 --- a/extension/llm/serving/BUCK +++ b/extension/llm/serving/BUCK @@ -17,10 +17,10 @@ fbcode_target(_kind = runtime.cxx_library, srcs = ["serving_runtime.cpp"], headers = [ "detail/generation_bridge.h", - "detail/prompt_preparer.h", "detail/text_output.h", ], exported_headers = [ + "detail/prompt_preparer.h", "request_handle.h", "serving_runtime.h", "types.h", From 69c7ce21f518ee6256bdcbc2de6f8ff685d3e7b0 Mon Sep 17 00:00:00 2001 From: pytorchbot Date: Thu, 5 Nov 2026 01:13:14 -0800 Subject: [PATCH 086/128] [executorch][cuda] Run CudaExecutor steps from prepared embeddings (#24653) This PR was created by the merge bot to help merge the original PR into the main branch. ghstack PR number: https://github.com/pytorch/executorch/pull/24063 by @Gasoonjia ^ Please use this as the source of truth for the PR details, comments, and reviews ghstack PR base: https://github.com/pytorch/executorch/tree/gh/Gasoonjia/86/base ghstack PR head: https://github.com/pytorch/executorch/tree/gh/Gasoonjia/86/head Merge bot PR base: https://github.com/pytorch/executorch/tree/gh/Gasoonjia/85/orig Merge bot PR head: https://github.com/pytorch/executorch/tree/gh/Gasoonjia/86/orig Differential Revision: [D125987291](https://our.internmc.facebook.com/intern/diff/D125987291/) @diff-train-skip-merge Co-authored-by: gasoonjia --- backends/cuda/batching/cuda_executor.cpp | 205 +++++++++++++++++- backends/cuda/batching/cuda_executor.h | 43 ++++ .../llm/batching/test/session_table_test.cpp | 48 ++++ extension/llm/batching/util/session_table.cpp | 16 +- extension/llm/batching/util/session_table.h | 3 + 5 files changed, 309 insertions(+), 6 deletions(-) diff --git a/backends/cuda/batching/cuda_executor.cpp b/backends/cuda/batching/cuda_executor.cpp index 1e4567fc7b7..6f357e5443b 100644 --- a/backends/cuda/batching/cuda_executor.cpp +++ b/backends/cuda/batching/cuda_executor.cpp @@ -90,6 +90,37 @@ Result step_width(const MethodMeta& meta, const char* method) { return static_cast(token_sizes[1]); } +// kEmbeddingsMethod's decoder input: [1, T, hidden] in a float dtype, with +// Long[T] positions and a rank-one Long selector. Returns T's upper bound. +Result> embeddings_width( + const MethodMeta& meta, + ::executorch::aten::ScalarType* dtype) { + const auto embeddings = meta.input_tensor_meta(0); + const auto positions = meta.input_tensor_meta(1); + const auto selector = meta.input_tensor_meta(2); + ET_CHECK_OR_RETURN_ERROR( + meta.num_inputs() == 3 && embeddings.ok() && positions.ok() && + selector.ok(), + InvalidProgram, + "CudaExecutor: %s must take embeddings, positions and a logits selector", + kEmbeddingsMethod); + const auto sizes = embeddings->sizes(); + const auto position_sizes = positions->sizes(); + ET_CHECK_OR_RETURN_ERROR( + is_supported_logits_type(embeddings->scalar_type()) && + sizes.size() == 3 && sizes[0] == 1 && sizes[1] > 0 && sizes[2] > 0 && + positions->scalar_type() == ::executorch::aten::ScalarType::Long && + position_sizes.size() == 1 && position_sizes[0] == sizes[1] && + selector->scalar_type() == ::executorch::aten::ScalarType::Long && + selector->sizes().size() == 1, + InvalidProgram, + "CudaExecutor: %s must take [1, T, hidden] embeddings, Long[T] positions " + "and a rank-one Long selector", + kEmbeddingsMethod); + *dtype = embeddings->scalar_type(); + return std::pair{static_cast(sizes[1]), sizes[2]}; +} + // The logits width `method` produces. Result logits_width(const MethodMeta& meta, const char* method) { ET_CHECK_OR_RETURN_ERROR( @@ -490,8 +521,27 @@ Result> CudaExecutor::create( max_sessions, seq_limit.value_or(0)); + std::optional embeddings_method; + if (names.count(kEmbeddingsMethod) != 0) { + ET_ASSIGN_OR_RETURN(meta, module->method_meta(kEmbeddingsMethod)); + ::executorch::aten::ScalarType dtype{}; + ET_ASSIGN_OR_RETURN(width, embeddings_width(meta, &dtype)); + ET_ASSIGN_OR_RETURN(vocab, logits_width(meta, kEmbeddingsMethod)); + ET_CHECK_OK_OR_RETURN_ERROR(backend_of(meta, kEmbeddingsMethod).error()); + ET_CHECK_OR_RETURN_ERROR( + vocab == method_vocab && width.first <= max_step_tokens && + min_selected_rows <= width.first, + InvalidProgram, + "CudaExecutor: %s disagrees with the token forwards", + kEmbeddingsMethod); + // Exported, like forward_others, from min_selected_rows tokens; narrower + // steps pad up to it. + embeddings_method = + EmbeddingsMethod{width.first, min_selected_rows, width.second, dtype}; + } + ET_CHECK_OK_OR_RETURN_ERROR(set_backend_options(options, methods)); - return std::unique_ptr(new CudaExecutor( + auto executor = std::unique_ptr(new CudaExecutor( std::move(module), std::move(cache), max_sessions, @@ -502,6 +552,8 @@ Result> CudaExecutor::create( min_selected_rows, has_sampler, has_argmax)); + executor->embeddings_method_ = embeddings_method; + return executor; } bool CudaExecutor::initialize() { @@ -524,6 +576,15 @@ bool CudaExecutor::initialize() { return false; } } + if (embeddings_method_ && + module_->load_method( + kEmbeddingsMethod, + /*planned_memory=*/nullptr, + /*event_tracer=*/nullptr, + &options_map) != Error::Ok) { + ET_LOG(Error, "CudaExecutor: could not load %s", kEmbeddingsMethod); + return false; + } for (const auto& [present, name] : {std::pair{has_sampler_, kSampleMethod}, std::pair{has_argmax_, kArgmaxMethod}}) { @@ -532,6 +593,11 @@ bool CudaExecutor::initialize() { return false; } } + if (embedder_ && embedder_->initialize && + embedder_->initialize() != Error::Ok) { + ET_LOG(Error, "CudaExecutor: the input embedder failed to initialize"); + return false; + } initialized_ = true; return true; } @@ -555,6 +621,25 @@ void CudaExecutor::set_sampling( sessions_.set_sampling(session, params, seed); } +bool CudaExecutor::accepts(const llm_batching::PreparedInput& input) const { + return embedder_ && embedder_->accepts && embedder_->accepts(input); +} + +Error CudaExecutor::set_input_embedder(InputEmbedder embedder) { + ET_CHECK_OR_RETURN_ERROR( + !initialized_ && embedder.accepts && embedder.materialize, + InvalidArgument, + "CudaExecutor: an embedder needs accepts and materialize, before " + "initialize()"); + ET_CHECK_OR_RETURN_ERROR( + embeddings_method_.has_value(), + NotSupported, + "CudaExecutor: the program exports no %s", + kEmbeddingsMethod); + embedder_ = std::move(embedder); + return Error::Ok; +} + bool CudaExecutor::execute_raw( const RawTargetStep& step, const std::function& consume) { @@ -590,6 +675,9 @@ bool CudaExecutor::execute_raw( } bool& flag; } reset(raw_executing_); + if (step.embeddings) { + return execute_embeddings(step, consume); + } for (const auto& slice : plan_slices(total, methods_)) { const int off = slice.offset; const int n = slice.length; @@ -756,6 +844,18 @@ bool CudaExecutor::execute(const BatchInput& batch, BatchOutput& out) { } RawTargetStep raw; + if (step->prepared) { + if (!embedder_) { + ET_LOG(Error, "CudaExecutor: a prepared input needs an input embedder"); + return false; + } + auto embeddings = embedder_->materialize(batch.inputs); + if (!embeddings.ok()) { + ET_LOG(Error, "CudaExecutor: could not materialize the batch's inputs"); + return false; + } + raw.embeddings = std::move(*embeddings); + } raw.tokens = std::move(step->tokens); raw.positions = std::move(step->positions); raw.seq_ids = std::move(step->seq_ids); @@ -954,11 +1054,112 @@ OffGraphKVMetrics CudaExecutor::kv_metrics() const { std::vector CudaExecutor::method_calls() const { std::vector calls; - calls.reserve(methods_.size()); + calls.reserve(methods_.size() + 1); for (std::size_t i = 0; i < methods_.size(); ++i) { calls.push_back({methods_[i].name, calls_[i]}); } + if (embeddings_method_) { + calls.push_back({kEmbeddingsMethod, embeddings_method_->calls}); + } return calls; } +bool CudaExecutor::execute_embeddings( + const RawTargetStep& step, + const std::function& consume) { + using ::executorch::aten::SizesType; + const auto& embeddings = *step.embeddings; + const int total = static_cast(step.positions.size()); + if (!embeddings_method_ || embeddings.dim() != 3 || embeddings.size(0) != 1 || + embeddings.size(1) != total || + embeddings.size(2) != embeddings_method_->hidden || + embeddings.scalar_type() != embeddings_method_->dtype || + !embeddings.device().is_cpu() || !is_dense(embeddings) || total <= 0 || + embeddings.const_data_ptr() == nullptr) { + ET_LOG( + Error, + "CudaExecutor: embeddings must be host [1, %d, %" PRId64 "] tensors", + total, + embeddings_method_ ? embeddings_method_->hidden : 0); + return false; + } + const auto& method = *embeddings_method_; + const std::size_t row_bytes = static_cast(method.hidden) * + ::executorch::runtime::elementSize(method.dtype); + const auto* data = + static_cast(embeddings.const_data_ptr()); + for (int off = 0; off < total; off += method.max_tokens) { + const int n = std::min(method.max_tokens, total - off); + const int width = std::max(n, method.min_tokens); + std::vector selected; + std::vector indices; + for (std::size_t i = 0; i < step.logit_indices.size(); ++i) { + const auto index = step.logit_indices[i]; + if (index >= off && index < off + n) { + selected.push_back(index - off); + indices.push_back(i); + } + } + if (selected.size() > static_cast(method.max_tokens)) { + return false; + } + const std::size_t rows = + std::max(selected.size(), min_selected_rows_); + selected.resize(rows, selected.empty() ? 0 : selected.back()); + if (!ctl_->declare_step(std::vector( + step.seq_ids.begin() + off, step.seq_ids.begin() + off + n))) { + return false; + } + // Rows past n are padding the cache writes to its scratch cell. + std::vector rows_bytes( + static_cast(width) * row_bytes, 0); + std::copy( + data + static_cast(off) * row_bytes, + data + static_cast(off + n) * row_bytes, + rows_bytes.begin()); + auto input = make_tensor_ptr( + {1, + static_cast(width), + static_cast(method.hidden)}, + std::move(rows_bytes), + method.dtype); + std::vector position_values( + step.positions.begin() + off, step.positions.begin() + off + n); + position_values.resize(width, 0); + auto positions = make_tensor_ptr( + {static_cast(width)}, std::move(position_values)); + auto selector = + make_tensor_ptr({static_cast(rows)}, std::move(selected)); + auto result = + module_->execute(kEmbeddingsMethod, {input, positions, selector}); + if (!result.ok() || result->empty() || !result->at(0).isTensor()) { + ET_LOG(Error, "CudaExecutor: %s failed", kEmbeddingsMethod); + return false; + } + ++embeddings_method_->calls; + const auto logits = result->at(0).toTensor(); + if (logits.dim() < 2 || logits.size(logits.dim() - 1) != vocab_size_ || + logits.numel() != static_cast(rows) * vocab_size_ || + !is_supported_logits_type(logits.scalar_type()) || !is_dense(logits)) { + return false; + } + std::optional<::executorch::aten::Tensor> taps; + if (result->size() > 1) { + if (!result->at(1).isTensor()) { + return false; + } + taps = result->at(1).toTensor(); + if (taps->dim() != 3 || taps->size(0) != 1 || taps->size(1) != width || + taps->size(2) <= 0 || + !is_supported_logits_type(taps->scalar_type()) || !is_dense(*taps)) { + return false; + } + } + if (!consume({off, n, logits, taps ? &*taps : nullptr, indices})) { + return false; + } + } + return true; +} + } // namespace executorch::backends::cuda::batching diff --git a/backends/cuda/batching/cuda_executor.h b/backends/cuda/batching/cuda_executor.h index b117815fbd3..ccd96afb9eb 100644 --- a/backends/cuda/batching/cuda_executor.h +++ b/backends/cuda/batching/cuda_executor.h @@ -33,6 +33,7 @@ #include #include #include +#include #include #include // ET_EXPERIMENTAL @@ -56,6 +57,10 @@ inline constexpr char kMinSelectedRowsMethod[] = "get_min_prefill_chunk"; // both returning Long [R], and only the tokens come back to the host. inline constexpr char kSampleMethod[] = "sample"; inline constexpr char kArgmaxMethod[] = "sample_argmax"; +// Optional dynamic forward over the same cache that takes decoder inputs +// rather than tokens: (embeddings [1, T, hidden], Long[T] positions, Long[R] +// selector). It runs only the steps carrying prepared inputs, e.g. images. +inline constexpr char kEmbeddingsMethod[] = "forward_from_embeddings"; // Process-wide CUDA backend options create() sets before the methods load. struct CudaExecutorOptions { @@ -97,6 +102,22 @@ struct RawTargetStep { // Global token-row indices; -1 discards a result. Repeats and reordering are // allowed. Callback selected_indices maps rows back into this vector. std::vector logit_indices; + // Host [1, tokens, hidden] decoder inputs. When set, the step runs through + // kEmbeddingsMethod and `tokens` is not read. + ::executorch::extension::TensorPtr embeddings; +}; + +// Turns a batch holding prepared inputs into its decoder inputs, for a model +// whose prompts carry more than tokens. Runs on the engine thread. +struct InputEmbedder { + // Loads what materialize needs; called from initialize(). + std::function<::executorch::runtime::Error()> initialize; + std::function accepts; + // Host [1, rows, hidden] embeddings for every row of `inputs`, in order. + std::function< + ::executorch::runtime::Result<::executorch::extension::TensorPtr>( + const std::vector&)> + materialize; }; struct RawTargetOutput { @@ -162,6 +183,16 @@ class ET_EXPERIMENTAL CudaExecutor : public llm_batching::Executor { bool execute( const llm_batching::BatchInput& batch, llm_batching::BatchOutput& out) override; + // Whether the installed embedder takes `input`; false without one. + bool accepts(const llm_batching::PreparedInput& input) const override; + + // Installs the embedder for prepared inputs, before initialize(). The program + // must export kEmbeddingsMethod. + ::executorch::runtime::Error set_input_embedder(InputEmbedder embedder); + // The decoder input width kEmbeddingsMethod takes, or 0 without it. + std::int64_t embeddings_hidden_size() const { + return embeddings_method_ ? embeddings_method_->hidden : 0; + } // Engine-thread-only raw access for model adapters. Output storage is // borrowed until the callback returns: copy/consume every needed output @@ -230,6 +261,9 @@ class ET_EXPERIMENTAL CudaExecutor : public llm_batching::Executor { std::optional> sample_on_device( const ::executorch::aten::Tensor& logits, const std::vector& sessions); + bool execute_embeddings( + const RawTargetStep& step, + const std::function& consume); // Ordered so the module dies first, releasing the delegates that resolved // the cache before the registry entry naming it goes. @@ -243,6 +277,15 @@ class ET_EXPERIMENTAL CudaExecutor : public llm_batching::Executor { // Sorted by max_tokens, ascending; the dynamic method, if any, is last. std::vector methods_; std::vector calls_; + struct EmbeddingsMethod { + int max_tokens; + int min_tokens; + std::int64_t hidden; + ::executorch::aten::ScalarType dtype; + std::uint64_t calls = 0; + }; + std::optional embeddings_method_; + std::optional embedder_; int min_selected_rows_; llm_batching::util::SessionTable sessions_; bool has_sampler_; diff --git a/extension/llm/batching/test/session_table_test.cpp b/extension/llm/batching/test/session_table_test.cpp index 0efcb0a36fa..079ccf6c64f 100644 --- a/extension/llm/batching/test/session_table_test.cpp +++ b/extension/llm/batching/test/session_table_test.cpp @@ -91,6 +91,54 @@ TEST_F(SessionTableTest, PacksInputsInOrderWithTheirRows) { EXPECT_EQ(step->logit_indices, std::vector({2, -1})); } +// A prompt whose rows only its executor can embed. +class OpaquePrompt final : public batching::PreparedInput { + public: + explicit OpaquePrompt(std::size_t rows) : rows_(rows) {} + const void* kind() const override { + static char kind; + return &kind; + } + std::size_t size() const override { + return rows_; + } + batching::PreparedInputPtr suffix(std::size_t) const override { + return nullptr; + } + + private: + std::size_t rows_; +}; + +TEST_F(SessionTableTest, PreparedRowsPackAsPlaceholders) { + const auto a = *table_->open(); + const auto b = *table_->open(); + batching::Input prompt; + prompt.sid = a; + prompt.payload = + batching::PreparedInputPtr(std::make_shared(4)); + prompt.offset = 1; + prompt.size = 3; + prompt.position = 0; + prompt.produce_output = true; + batching::BatchInput batch; + batch.inputs = {input(b, {5, 6}, 0), prompt}; + ASSERT_EQ(table_->pack(batch).error(), Error::InvalidArgument); + prompt.offset = 0; + batch.inputs = {input(b, {5, 6}, 0), prompt}; + const auto step = table_->pack(batch); + ASSERT_EQ(step.error(), Error::Ok); + EXPECT_TRUE(step->prepared); + EXPECT_EQ(step->tokens, std::vector({5, 6, 0, 0, 0})); + EXPECT_EQ(step->positions, std::vector({0, 1, 0, 1, 2})); + EXPECT_EQ(step->logit_indices, std::vector({1, 4})); + prompt.size = 5; + batch.inputs = {prompt}; + EXPECT_EQ(table_->pack(batch).error(), Error::InvalidArgument); + batch.inputs = {input(b, {5, 6}, 0)}; + EXPECT_FALSE(table_->pack(batch)->prepared); +} + TEST_F(SessionTableTest, ConsecutiveChunksOfOnePromptAbut) { const auto a = *table_->open(); batching::BatchInput batch; diff --git a/extension/llm/batching/util/session_table.cpp b/extension/llm/batching/util/session_table.cpp index ab2333783cb..3be64ba5f1d 100644 --- a/extension/llm/batching/util/session_table.cpp +++ b/extension/llm/batching/util/session_table.cpp @@ -195,8 +195,14 @@ Result SessionTable::pack(const BatchInput& batch) { const std::int32_t seq_id = seq_it->second.seq_id; const auto* tokens = std::get_if(&input.payload); const auto* token_input = tokens && *tokens ? tokens->get() : nullptr; - if (input.size == 0 || !token_input || input.offset > token_input->size() || - input.size > token_input->size() - input.offset) { + const auto* prepared = std::get_if(&input.payload); + const auto* prepared_input = + prepared && *prepared ? prepared->get() : nullptr; + const std::size_t available = token_input ? token_input->size() + : prepared_input ? prepared_input->size() + : 0; + if (input.size == 0 || (!token_input && !prepared_input) || + input.offset > available || input.size > available - input.offset) { ET_LOG( Error, "pack: session %" PRId64 " gave a slice its tokens do not hold", @@ -254,9 +260,11 @@ Result SessionTable::pack(const BatchInput& batch) { return Error::OutOfResources; } - const Token* slice = token_input->data() + input.offset; + const Token* slice = + token_input ? token_input->data() + input.offset : nullptr; + step.prepared = step.prepared || prepared_input != nullptr; for (std::size_t k = 0; k < input.size; ++k) { - step.tokens.push_back(static_cast(slice[k])); + step.tokens.push_back(slice ? static_cast(slice[k]) : 0); step.positions.push_back(start + static_cast(k)); } step.seq_ids.insert(step.seq_ids.end(), input.size, seq_id); diff --git a/extension/llm/batching/util/session_table.h b/extension/llm/batching/util/session_table.h index 756683302de..b61c4e2a53c 100644 --- a/extension/llm/batching/util/session_table.h +++ b/extension/llm/batching/util/session_table.h @@ -43,6 +43,9 @@ struct ET_EXPERIMENTAL PackedStep { std::vector seq_ids; // Per input: the token whose logits it wants, or -1 for none. std::vector logit_indices; + // Some input is a PreparedInput: its rows hold placeholder tokens, so the + // step must run from the executor's materialized embeddings. + bool prepared = false; }; // The logits rows a forward over tokens [offset, offset + length) must select: From 187fac49aa8159c79cf981366d73d88d04c68d67 Mon Sep 17 00:00:00 2001 From: pytorchbot Date: Thu, 5 Nov 2026 01:13:14 -0800 Subject: [PATCH 087/128] [executorch][muse] Export vision with the CUDA batching contract (#24654) This PR was created by the merge bot to help merge the original PR into the main branch. ghstack PR number: https://github.com/pytorch/executorch/pull/24064 by @Gasoonjia ^ Please use this as the source of truth for the PR details, comments, and reviews ghstack PR base: https://github.com/pytorch/executorch/tree/gh/Gasoonjia/87/base ghstack PR head: https://github.com/pytorch/executorch/tree/gh/Gasoonjia/87/head Merge bot PR base: https://github.com/pytorch/executorch/tree/gh/Gasoonjia/86/orig Merge bot PR head: https://github.com/pytorch/executorch/tree/gh/Gasoonjia/87/orig Differential Revision: [D125987292](https://our.internmc.facebook.com/intern/diff/D125987292/) @diff-train-skip-merge Co-authored-by: gasoonjia --- .../muse-glimmer/export/export_dflash.py | 44 +++++--- .../models/muse-glimmer/export/export_solo.py | 100 ++++++++++++++--- .../tests/test_cuda_batching_pipeline.py | 106 ++++++++++++++++++ 3 files changed, 217 insertions(+), 33 deletions(-) diff --git a/examples/models/muse-glimmer/export/export_dflash.py b/examples/models/muse-glimmer/export/export_dflash.py index f28af1b6114..479b8a033a8 100644 --- a/examples/models/muse-glimmer/export/export_dflash.py +++ b/examples/models/muse-glimmer/export/export_dflash.py @@ -188,8 +188,6 @@ def export_dflash( mmproj loader and vision export contract to add ``vision_encoder``. """ validate_dflash_export_options(backend, use_offgraph_kv_cache) - if use_offgraph_kv_cache and backend == "cuda" and mmproj is not None: - raise ValueError("the CUDA batching DFlash export is text-only") import executorch.extension.llm.export.gguf # noqa: F401 import executorch.extension.llm.export.int4 # noqa: F401 @@ -247,6 +245,29 @@ def export_dflash( activation_dtype=activation_dtype, ) + vision_model = None + pos_embed_table = None + if mmproj is not None: + from executorch.examples.models.muse_glimmer.loaders.checkpoint_loader import ( + load_mmproj_vision_model, + ) + + print("=" * 60) + print("Loading vision encoder...") + print("=" * 60) + vision_model, pos_embed_table, _ = load_mmproj_vision_model( + mmproj, + backend=backend, + activation_dtype=activation_dtype, + ) + + if ( + draft_model is not None + and use_offgraph_kv_cache + and backend == "cuda" + and vision_model is not None + ): + raise ValueError("the CUDA batching DFlash export does not support vision yet") if use_offgraph_kv_cache and backend == "cuda": from executorch.examples.models.muse_glimmer.export.export_solo import ( DEFAULT_FORWARD_SIZES, @@ -265,25 +286,12 @@ def export_dflash( dflash_draft=draft_model, max_draft_owners=max_draft_owners, draft_max_cells=draft_max_cells, + vision_model=vision_model, + pos_embed_table=pos_embed_table, + max_vision_patches=max_vision_patches, ) return - vision_model = None - pos_embed_table = None - if mmproj is not None: - from executorch.examples.models.muse_glimmer.loaders.checkpoint_loader import ( - load_mmproj_vision_model, - ) - - print("=" * 60) - print("Loading vision encoder...") - print("=" * 60) - vision_model, pos_embed_table, _ = load_mmproj_vision_model( - mmproj, - backend=backend, - activation_dtype=activation_dtype, - ) - backend_export = _export_dflash_mlx if backend == "mlx" else _export_dflash_cuda backend_export( target_model, diff --git a/examples/models/muse-glimmer/export/export_solo.py b/examples/models/muse-glimmer/export/export_solo.py index 9fe39ce6df5..7c480b122cf 100644 --- a/examples/models/muse-glimmer/export/export_solo.py +++ b/examples/models/muse-glimmer/export/export_solo.py @@ -11,8 +11,10 @@ ``--use-offgraph-kv-cache`` keeps the KV cache out of the program and exports the batching runtime's contract. On MLX that is the selected-logits decoder. On -CUDA it is the text-only artifact CudaExecutor (backends/cuda/batching) drives; -without the flag CUDA keeps its in-graph prefill and decode methods: +CUDA it is the artifact CudaExecutor (backends/cuda/batching) drives: the text +forwards plus, with ``--mmproj``, ``vision_encoder``/``embed_text``/ +``forward_from_embeddings``; without the flag CUDA keeps its in-graph prefill +and decode methods: - ``forward_{N}`` for each N in ``--forward-sizes`` (default 1, 2, 4, ..., 64): T = K = N, static, each captured into a CUDA graph at runtime, its quantized @@ -133,8 +135,8 @@ def export_and_lower( if not 1 <= max_prefill_chunk <= config.max_seq_len: raise ValueError("max_prefill_chunk must be within [1, max_seq_len]") if backend == "cuda" and use_offgraph_kv_cache: - if vision_model is not None: - raise ValueError("the CUDA batching export is text-only") + if vision_fp32_mm != "none": + raise ValueError("CUDA batching does not support vision_fp32_mm") export_cuda_batching( model, config, @@ -143,6 +145,9 @@ def export_and_lower( max_cells=max_cells, forward_sizes=forward_sizes or DEFAULT_FORWARD_SIZES, device_sampling=sample, + vision_model=vision_model, + pos_embed_table=pos_embed_table, + max_vision_patches=max_vision_patches, ) elif backend == "cuda": _export_cuda( @@ -257,6 +262,10 @@ def step_width_spec(method: str) -> bytes: return b"1:0" +# Image prompts reach the batching cache through this method. +EMBEDDINGS_METHOD = "forward_from_embeddings" + + def step_forward( model: MuseGlimmerModel, tokens: torch.Tensor, @@ -268,7 +277,18 @@ def step_forward( The whole step runs through the decoder so every token's K/V reaches the cache; only the rows the batch samples from go through the LM head. """ - x = model.embed_text(tokens) + return embeddings_step_forward( + model, model.embed_text(tokens), input_pos, logits_to_keep + ) + + +def embeddings_step_forward( + model: MuseGlimmerModel, + x: torch.Tensor, + input_pos: torch.Tensor, + logits_to_keep: torch.Tensor, +) -> torch.Tensor: + """``step_forward`` from decoder inputs [1, T, dim], e.g. spliced images.""" taps = None if hasattr(model, "_dflash_target_layer_ids"): sliding_mask, global_mask = model._build_masks(input_pos) @@ -349,9 +369,16 @@ def export_cuda_batching( # noqa: C901 dflash_draft: nn.Module | None = None, max_draft_owners: int = 4, draft_max_cells: int | None = None, + vision_model: nn.Module | None = None, + pos_embed_table: torch.Tensor | None = None, + max_vision_patches: int = 16384, ): """Exports the forward methods and writes the artifact. + With ``vision_model`` the token forwards are unchanged, and image prompts run + through three more methods: ``vision_encoder``, ``embed_text`` and a dynamic + ``forward_from_embeddings`` over the same cache and weights. + Returns the ExecutorchProgramManager so callers can inspect it. """ import executorch.backends.cuda.quantize_op_dispatch # noqa: F401 @@ -379,6 +406,10 @@ def export_cuda_batching( # noqa: C901 ) from torch.export import Dim, export + has_vision = vision_model is not None and pos_embed_table is not None + if (vision_model is None) != (pos_embed_table is None): + raise ValueError("vision_model and pos_embed_table must be given together") + if dflash_draft is not None: from executorch.examples.models.muse_glimmer.export.export_dflash import ( prepare_cuda_packed_draft, @@ -462,10 +493,52 @@ def export_cuda_batching( # noqa: C901 dynamic_shapes=({1: width}, {0: width}, {0: rows}), strict=True, ) + if has_vision: + embeddings_width = Dim( + "embeddings_width", min=_CUDA_MIN_PREFILL_CHUNK, max=max_step_tokens + ) + with ( + common.BoundMethodForward( + model, types.MethodType(embeddings_step_forward, model) + ), + torch.no_grad(), + ): + print( + f"Exporting {EMBEDDINGS_METHOD} (T in [{_CUDA_MIN_PREFILL_CHUNK}, " + f"{max_step_tokens}])..." + ) + programs[EMBEDDINGS_METHOD] = export( + model, + ( + torch.zeros((1, max_step_tokens, config.dim), dtype=torch.bfloat16), + torch.arange(max_step_tokens, dtype=torch.long), + torch.zeros(max_step_tokens, dtype=torch.long), + ), + dynamic_shapes=( + {1: embeddings_width}, + {0: embeddings_width}, + {0: rows}, + ), + strict=True, + ) + with common.BoundMethodForward(model, model.embed_text), torch.no_grad(): + print("Exporting embed_text...") + programs["embed_text"] = export( + model, + (torch.zeros((1, max_step_tokens), dtype=torch.long),), + dynamic_shapes=({1: Dim("embed_width", min=1, max=max_step_tokens)},), + strict=True, + ) + programs["vision_encoder"] = common.export_vision_encoder( + vision_model, pos_embed_table, max_vision_patches + ) + del vision_model del model gc.collect() torch.cuda.empty_cache() - forward_methods = list(programs) + forward_methods = [ + name for name in programs if name not in ("embed_text", "vision_encoder") + ] if dflash_draft is not None: programs.update(draft_programs) del draft_programs, dflash_draft @@ -520,8 +593,8 @@ def partitioner(name: str) -> CudaPartitioner: max_prefill=max_step_tokens, activation_dtype=torch.bfloat16, mutable_buffer_metadata=None, - has_vision=False, - max_vision_patches=0, + has_vision=has_vision, + max_vision_patches=max_vision_patches, ) constant_methods.update(geometry) constant_methods.update(write_max_context_len(config.max_seq_len)) @@ -560,7 +633,7 @@ def partitioner(name: str) -> CudaPartitioner: del edge gc.collect() - common.save_pte(et_program, output_dir, None) + common.save_pte(et_program, output_dir, pos_embed_table) if torch.cuda.is_available(): peak_mb = torch.cuda.max_memory_allocated() / (1024**2) print(f"EXPORT_GPU_PEAK_MEMORY_MB: {peak_mb:.1f}") @@ -1040,8 +1113,9 @@ def main() -> None: "--use-offgraph-kv-cache", action="store_true", help="Allocate the KV cache at runtime and export the batching runtime's " - "contract: MLX's selected-logits decoder, or CUDA's text-only " - "forward_{N} + forward_others over a cell-layout cache. Without it CUDA " + "contract: MLX's selected-logits decoder, or CUDA's " + "forward_{N} + forward_others over a cell-layout cache (plus the vision " + "methods with --mmproj). Without it CUDA " "exports its in-graph prefill and decode methods.", ) parser.add_argument( @@ -1109,10 +1183,6 @@ def main() -> None: args = parser.parse_args() _validate_offgraph_prefill(parser, args) - if args.backend == "cuda" and args.use_offgraph_kv_cache and args.mmproj: - parser.error( - "--use-offgraph-kv-cache on CUDA exports a text-only model; omit --mmproj." - ) if args.backend == "cuda" and not torch.cuda.is_available(): parser.error("CUDA is required for the cuda backend.") diff --git a/examples/models/muse-glimmer/tests/test_cuda_batching_pipeline.py b/examples/models/muse-glimmer/tests/test_cuda_batching_pipeline.py index fcdc306e9d4..93f7bca8f04 100644 --- a/examples/models/muse-glimmer/tests/test_cuda_batching_pipeline.py +++ b/examples/models/muse-glimmer/tests/test_cuda_batching_pipeline.py @@ -174,6 +174,22 @@ def test_cell_manifest_keeps_every_layer(self) -> None: self.assertEqual(manifest["max_write"], 8) self.assertEqual(len(manifest["layers"]), TINY_CONFIG.n_layers) + def test_cuda_batching_rejects_vision_fp32_mm(self) -> None: + from executorch.examples.models.muse_glimmer.export.export_solo import ( + export_and_lower, + ) + + model = build_random_tiny_model() + with self.assertRaisesRegex(ValueError, "vision_fp32_mm"): + export_and_lower( + model, + TINY_CONFIG, + ".", + backend="cuda", + use_offgraph_kv_cache=True, + vision_fp32_mm="triton", + ) + class BatchingExportTest(unittest.TestCase): MAX_STEP = 16 @@ -184,6 +200,96 @@ def setUp(self) -> None: if not torch.cuda.is_available(): self.skipTest("CUDA required") + def test_vision_adds_embedding_methods_over_the_same_cache(self) -> None: + """Images add vision, embed_text and one dynamic embeddings forward.""" + try: + import gguf # noqa: F401 + except ImportError: + self.skipTest("gguf package required") + from executorch.backends.cuda.passes.lower_offgraph_kv import ( + OFFGRAPH_KV_COMPILE_SPEC, + OFFGRAPH_KV_STEP_WIDTH_COMPILE_SPEC, + ) + from executorch.examples.models.muse_glimmer.loaders.checkpoint_loader import ( + load_mmproj_vision_model, + ) + from executorch.examples.models.muse_glimmer.tests.test_pipeline import ( + _mmproj_vision_config, + build_mmproj_gguf, + ) + from executorch.exir.lowered_backend_module import get_lowered_submodules + + with ( + tempfile.TemporaryDirectory() as ckpt_dir, + tempfile.TemporaryDirectory() as out_dir, + ): + save_checkpoint(ckpt_dir) + model, config = load_prequantized_model( + ckpt_dir, max_seq_len=TINY_CONFIG.max_seq_len + ) + mmproj = os.path.join(ckpt_dir, "mmproj.gguf") + build_mmproj_gguf(mmproj) + vision, pos_table, _ = load_mmproj_vision_model( + mmproj, config=_mmproj_vision_config(), backend="cuda" + ) + program = export_cuda_batching( + model, + config, + out_dir, + max_step_tokens=self.MAX_STEP, + forward_sizes=self.FORWARD_SIZES, + max_cells=self.MAX_CELLS, + vision_model=vision, + pos_embed_table=pos_table, + max_vision_patches=64, + ) + self.assertTrue(os.path.exists(os.path.join(out_dir, "pos_embed.bin"))) + methods = set(program.methods) + self.assertLessEqual( + {"forward_from_embeddings", "embed_text", "vision_encoder"}, methods + ) + embeddings = program.exported_program("forward_from_embeddings") + lowered = get_lowered_submodules(embeddings.graph_module) + self.assertEqual(len(lowered), 1) + specs = {spec.key: spec.value for spec in lowered[0][1].compile_specs} + # Same cache and step-width contract as the token forwards. + others = get_lowered_submodules( + program.exported_program("forward_others").graph_module + )[0][1].compile_specs + self.assertEqual( + specs[OFFGRAPH_KV_COMPILE_SPEC], + {s.key: s.value for s in others}[OFFGRAPH_KV_COMPILE_SPEC], + ) + self.assertEqual(specs[OFFGRAPH_KV_STEP_WIDTH_COMPILE_SPEC], b"1:0") + for name in ("embed_text", "vision_encoder"): + sub = get_lowered_submodules( + program.exported_program(name).graph_module + ) + self.assertEqual(len(sub), 1, name) + self.assertNotIn( + OFFGRAPH_KV_COMPILE_SPEC, + {spec.key for spec in sub[0][1].compile_specs}, + name, + ) + + from executorch.runtime import Runtime, Verification + + loaded = Runtime.get().load_program( + os.path.join(out_dir, "model.pte"), verification=Verification.Minimal + ) + meta = loaded.metadata("forward_from_embeddings") + self.assertEqual(meta.num_inputs(), 3) + self.assertEqual( + meta.input_tensor_meta(0).sizes(), (1, self.MAX_STEP, TINY_CONFIG.dim) + ) + self.assertEqual( + loaded.load_method("has_vision_encoder").execute([])[0], True + ) + self.assertEqual( + loaded.load_method("get_vision_hidden_size").execute([])[0], + TINY_CONFIG.dim, + ) + def test_exports_forward_methods_over_a_cell_cache(self) -> None: from executorch.backends.cuda.passes.lower_offgraph_kv import ( OFFGRAPH_KV_COMPILE_SPEC, From 7dbdd807ba934d74b7d4b7432c0a25de28d20b56 Mon Sep 17 00:00:00 2001 From: pytorchbot Date: Thu, 5 Nov 2026 01:13:15 -0800 Subject: [PATCH 088/128] [executorch][muse] Serve images on CUDA batching (#24655) This PR was created by the merge bot to help merge the original PR into the main branch. ghstack PR number: https://github.com/pytorch/executorch/pull/24065 by @Gasoonjia ^ Please use this as the source of truth for the PR details, comments, and reviews ghstack PR base: https://github.com/pytorch/executorch/tree/gh/Gasoonjia/88/base ghstack PR head: https://github.com/pytorch/executorch/tree/gh/Gasoonjia/88/head Merge bot PR base: https://github.com/pytorch/executorch/tree/gh/Gasoonjia/87/orig Merge bot PR head: https://github.com/pytorch/executorch/tree/gh/Gasoonjia/88/orig Differential Revision: [D125987293](https://our.internmc.facebook.com/intern/diff/D125987293/) @diff-train-skip-merge Co-authored-by: gasoonjia --- .../models/muse-glimmer/BATCHING_SERVING.md | 16 ++-- .../muse-glimmer/runtime/backend_factory.cpp | 13 +--- .../runtime/cuda_dflash_executor.cpp | 73 +++++++++++++++++++ .../runtime/cuda_dflash_executor.h | 12 +++ .../muse-glimmer/serving/serve_batching.py | 8 +- .../muse-glimmer/tests/test_serve_batching.py | 24 +++--- 6 files changed, 113 insertions(+), 33 deletions(-) diff --git a/examples/models/muse-glimmer/BATCHING_SERVING.md b/examples/models/muse-glimmer/BATCHING_SERVING.md index e20422bba73..90254af2722 100644 --- a/examples/models/muse-glimmer/BATCHING_SERVING.md +++ b/examples/models/muse-glimmer/BATCHING_SERVING.md @@ -1,8 +1,8 @@ # Muse Glimmer Batching Serving -The batching runner and multiplexed worker support MLX autoregressive generation -with text only or text plus one image across the submitted conversation, and -CUDA autoregressive generation with text only. They are opt-in entry points; the +The batching runner and multiplexed worker support MLX and CUDA autoregressive +generation with text only or text plus one image across the submitted +conversation. They are opt-in entry points; the legacy runners and export defaults remain unchanged. DFlash batching runs on MLX and CUDA (text only). CUDA specifics are in [CUDA](#cuda) below. @@ -205,8 +205,10 @@ python -m executorch.examples.models.muse_glimmer.export.export_solo \ `--forward-sizes` (default `1,2,4,8,16,32,64`), `--max-step-tokens` (default 4096) and `--max-cells` (default 65536) size the methods and the shared KV -pool; `--logits` samples on the host instead. The artifact is text only, so -`--mmproj` is rejected. +pool; `--logits` samples on the host instead. Add `--mmproj` for images: the +token forwards are unchanged, and the artifact gains `vision_encoder`, +`embed_text` and a dynamic `forward_from_embeddings` over the same cache, which +runs only the prefill steps that carry an image. Build with `make muse-glimmer-batching-cuda` on Linux, then serve: @@ -221,8 +223,8 @@ python -m executorch.examples.models.muse_glimmer.serving.serve_batching \ --max-context 8192 ``` -`--data-path` is required and `--pos-embed-path` is rejected; image requests -fail preparation. Every resident session reserves `--max-context` cells of the +`--data-path` is required. Image artifacts read `pos_embed.bin` beside +`model.pte`, or `--pos-embed-path`. Every resident session reserves `--max-context` cells of the pool: requests, `--prefix-cache-entries` snapshots, and the resolved number of capture slots when snapshots are enabled. `--prefix-capture-slots 0` (the default) matches request concurrency, capped at two when checkpoints are enabled; a diff --git a/examples/models/muse-glimmer/runtime/backend_factory.cpp b/examples/models/muse-glimmer/runtime/backend_factory.cpp index 0916ceb4fb0..e94b58e31fa 100644 --- a/examples/models/muse-glimmer/runtime/backend_factory.cpp +++ b/examples/models/muse-glimmer/runtime/backend_factory.cpp @@ -58,17 +58,8 @@ runtime::Result create_muse_glimmer_backend( session_tokens > 1, InvalidArgument, "Muse Glimmer CUDA: sessions need room for a prompt and prediction"); - auto spec = std::make_shared( - MuseGlimmerPreparationSpec{ - aten::ScalarType::BFloat16, - /*hidden_dim=*/0, - session_tokens, - static_cast(executor->preferred_batch_tokens()), - vocab, - /*has_vision=*/false, - /*max_soft_tokens=*/0, - config.image_limits, - config.bos_id}); + ET_ASSIGN_OR_RETURN( + spec, make_cuda_preparation(*executor, config, session_tokens)); return MuseGlimmerBackend{std::move(executor), std::move(spec)}; } #endif diff --git a/examples/models/muse-glimmer/runtime/cuda_dflash_executor.cpp b/examples/models/muse-glimmer/runtime/cuda_dflash_executor.cpp index 0389cd5a2f8..68ae07d6f2a 100644 --- a/examples/models/muse-glimmer/runtime/cuda_dflash_executor.cpp +++ b/examples/models/muse-glimmer/runtime/cuda_dflash_executor.cpp @@ -7,6 +7,8 @@ */ #include +#include + #include #include #include @@ -462,6 +464,77 @@ MuseGlimmerCudaDFlashExecutor::~MuseGlimmerCudaDFlashExecutor() { } } +Result> make_cuda_preparation( + cb::CudaExecutor& target, + const MuseGlimmerBackendConfig& config, + std::int32_t max_context, + std::shared_ptr* embeddings) { + ET_ASSIGN_OR_RETURN(names, target.module().method_names()); + const bool has_vision = names.count("vision_encoder") != 0; + const auto hidden = + static_cast(target.embeddings_hidden_size()); + std::int64_t max_soft_tokens = 0; + if (has_vision) { + ET_CHECK_OR_RETURN_ERROR( + hidden > 0, + InvalidProgram, + "Muse Glimmer CUDA: vision needs %s", + cb::kEmbeddingsMethod); + ET_ASSIGN_OR_RETURN( + soft_tokens, + muse_glimmer_abi::check_vision_abi( + target.module(), + hidden, + aten::ScalarType::BFloat16, + config.max_vision_patches, + ::executorch::backends::cuda::kCudaBackendId)); + max_soft_tokens = soft_tokens; + } + auto spec = std::make_shared( + MuseGlimmerPreparationSpec{ + aten::ScalarType::BFloat16, + has_vision ? hidden : 0, + max_context, + static_cast(target.preferred_batch_tokens()), + target.vocab_size(), + has_vision, + max_soft_tokens, + config.image_limits, + config.bos_id}); + if (!has_vision) { + ET_CHECK_OR_RETURN_ERROR( + config.pos_embed_path.empty(), + InvalidArgument, + "Muse Glimmer CUDA: the program has no vision_encoder, but --pos-embed-path was given (%s)", + config.pos_embed_path.c_str()); + return spec; + } + auto path = config.pos_embed_path; + if (path.empty()) { + const auto slash = config.model_path.find_last_of('/'); + path = (slash == std::string::npos ? "." + : config.model_path.substr(0, slash)) + + "/pos_embed.bin"; + } + auto model_embeddings = + std::make_shared(spec, std::move(path)); + Module* module = &target.module(); + ET_CHECK_OK_OR_RETURN_ERROR(target.set_input_embedder( + {[model_embeddings, module] { + return model_embeddings->initialize(*module); + }, + [model_embeddings](const batching::PreparedInput& input) { + return model_embeddings->accepts(input); + }, + [model_embeddings, module](const std::vector& inputs) { + return model_embeddings->materialize(*module, inputs); + }})); + if (embeddings) { + *embeddings = std::move(model_embeddings); + } + return spec; +} + Result MuseGlimmerCudaDFlashExecutor::create( const MuseGlimmerBackendConfig& config) { ET_CHECK_OR_RETURN_ERROR( diff --git a/examples/models/muse-glimmer/runtime/cuda_dflash_executor.h b/examples/models/muse-glimmer/runtime/cuda_dflash_executor.h index 4bda7961f39..481fdd2a55d 100644 --- a/examples/models/muse-glimmer/runtime/cuda_dflash_executor.h +++ b/examples/models/muse-glimmer/runtime/cuda_dflash_executor.h @@ -15,6 +15,18 @@ namespace executorch::extension::llm { +class MuseGlimmerModelEmbeddings; + +// The preparation spec for prompts to `target`. When the program has +// vision_encoder, also installs an image embedder on it; `embeddings` then +// receives it, for adapters that build their own target steps. +runtime::Result> +make_cuda_preparation( + ::executorch::backends::cuda::batching::CudaExecutor& target, + const MuseGlimmerBackendConfig& config, + std::int32_t max_context, + std::shared_ptr* embeddings = nullptr); + // Exact packed-export metadata contract. Geometry tensors use the ordinary // get_n_caches/get_kv_heads/get_head_dims/get_windows names with get_draft_. namespace cuda_dflash_detail { diff --git a/examples/models/muse-glimmer/serving/serve_batching.py b/examples/models/muse-glimmer/serving/serve_batching.py index e5d13f10461..1eea2213504 100644 --- a/examples/models/muse-glimmer/serving/serve_batching.py +++ b/examples/models/muse-glimmer/serving/serve_batching.py @@ -7,9 +7,9 @@ """Serve Muse Glimmer text/images with the batching multiplexed worker. Requires an off-graph batching export: export_solo.py --use-offgraph-kv-cache -for MLX (text and images); add --backend cuda for CUDA (text only). DFlash is -opt-in, MLX only, and requires a matching export; the legacy launcher and -runners remain separate. +for MLX; add --backend cuda for CUDA. Both serve text and images when exported +with --mmproj. DFlash is opt-in and requires a matching export; the legacy +launcher and runners remain separate. """ import argparse @@ -122,8 +122,6 @@ def _validate_backend(parser, args): return if args.data_path is None: parser.error("--backend cuda requires --data-path (aoti_cuda_blob.ptd)") - if args.pos_embed_path is not None: - parser.error("--backend cuda serves text only; omit --pos-embed-path") def _validate_paths(parser, args): diff --git a/examples/models/muse-glimmer/tests/test_serve_batching.py b/examples/models/muse-glimmer/tests/test_serve_batching.py index 9b1c767c6de..8879eb3900b 100644 --- a/examples/models/muse-glimmer/tests/test_serve_batching.py +++ b/examples/models/muse-glimmer/tests/test_serve_batching.py @@ -150,20 +150,24 @@ def test_cuda_launcher_forwards_dflash(arguments, tmp_path): ) -@pytest.mark.parametrize("case", ["no_weights", "pos_embed"]) -def test_cuda_launcher_rejects_missing_weights_or_vision(arguments, tmp_path, case): +def test_cuda_launcher_requires_the_weights_file(arguments): + with pytest.raises(SystemExit) as error: + serve_batching._parse_args(arguments + ["--backend", "cuda"]) + assert error.value.code == 2 + + +def test_cuda_worker_takes_the_vision_table(arguments, tmp_path): data = tmp_path / "aoti_cuda_blob.ptd" table = tmp_path / "pos_embed.bin" data.touch() table.touch() - argv = arguments + ["--backend", "cuda"] - if case != "no_weights": - argv += ["--data-path", str(data)] - if case == "pos_embed": - argv += ["--pos-embed-path", str(table)] - with pytest.raises(SystemExit) as error: - serve_batching._parse_args(argv) - assert error.value.code == 2 + args = serve_batching._parse_args( + arguments + + ["--backend", "cuda", "--data-path", str(data)] + + ["--pos-embed-path", str(table)] + ) + flags = dict(arg.split("=", 1) for arg in serve_batching._worker_command(args)[1:]) + assert flags["--pos_embed_path"] == str(table) @pytest.mark.parametrize("shortfall", [0, 1]) From 341223735f80d31f130f4b7a5f5b2d969a7b7f4c Mon Sep 17 00:00:00 2001 From: pytorchbot Date: Thu, 5 Nov 2026 01:13:15 -0800 Subject: [PATCH 089/128] [executorch][muse] Accept images in CUDA DFlash batching (#24656) This PR was created by the merge bot to help merge the original PR into the main branch. ghstack PR number: https://github.com/pytorch/executorch/pull/24066 by @Gasoonjia ^ Please use this as the source of truth for the PR details, comments, and reviews ghstack PR base: https://github.com/pytorch/executorch/tree/gh/Gasoonjia/89/base ghstack PR head: https://github.com/pytorch/executorch/tree/gh/Gasoonjia/89/head Merge bot PR base: https://github.com/pytorch/executorch/tree/gh/Gasoonjia/88/orig Merge bot PR head: https://github.com/pytorch/executorch/tree/gh/Gasoonjia/89/orig Differential Revision: [D125987295](https://our.internmc.facebook.com/intern/diff/D125987295/) @diff-train-skip-merge Co-authored-by: gasoonjia --- .ci/scripts/export_model_artifact.sh | 35 ++++------ .ci/scripts/muse_glimmer_batching_smoke.cpp | 2 + .ci/scripts/test_model_e2e.sh | 68 ++++++++++++++----- .github/workflows/cuda.yml | 2 +- .../models/muse-glimmer/BATCHING_SERVING.md | 2 +- .../muse-glimmer/export/export_dflash.py | 7 -- .../runtime/cuda_dflash_executor.cpp | 64 ++++++++++------- .../runtime/cuda_dflash_executor.h | 7 +- 8 files changed, 117 insertions(+), 70 deletions(-) diff --git a/.ci/scripts/export_model_artifact.sh b/.ci/scripts/export_model_artifact.sh index e69c65c213e..9a214964ce1 100755 --- a/.ci/scripts/export_model_artifact.sh +++ b/.ci/scripts/export_model_artifact.sh @@ -50,7 +50,7 @@ Arguments: - vr-streaming: Voxtral Realtime streaming mode - vr-offline: Voxtral Realtime offline mode - solo-text: Muse Glimmer solo text mode - - solo-text-batching: Muse Glimmer text on the CUDA batching runtime + - dflash-image-batching: Muse Glimmer DFlash text and image on the CUDA batching runtime - dflash-image: Muse Glimmer DFlash vision mode Examples: @@ -105,7 +105,7 @@ if [ -n "$MODE" ]; then exit 1 fi ;; - solo-text|solo-text-batching|dflash-image) + solo-text|dflash-image-batching|dflash-image) if [ "$HF_MODEL" != "meta-models/Muse-Glimmer-30B-GGUF" ]; then echo "Error: Mode '$MODE' can only be used with Muse Glimmer model" echo "Provided model: $HF_MODEL" @@ -114,7 +114,7 @@ if [ -n "$MODE" ]; then ;; *) echo "Error: Unsupported mode '$MODE'" - echo "Supported modes: vr-streaming, vr-offline, solo-text, solo-text-batching, dflash-image" + echo "Supported modes: vr-streaming, vr-offline, solo-text, dflash-image-batching, dflash-image" exit 1 ;; esac @@ -354,8 +354,8 @@ if [ "$MODEL_NAME" = "muse_glimmer" ]; then echo "Error: Muse Glimmer requires quantization 'kquant-17gb' or 'kquant-dynamic'" exit 1 fi - if [ "$MODE" != "solo-text" ] && [ "$MODE" != "solo-text-batching" ] && [ "$MODE" != "dflash-image" ]; then - echo "Error: Muse Glimmer requires mode 'solo-text', 'solo-text-batching' or 'dflash-image'" + if [ "$MODE" != "solo-text" ] && [ "$MODE" != "dflash-image-batching" ] && [ "$MODE" != "dflash-image" ]; then + echo "Error: Muse Glimmer requires mode 'solo-text', 'dflash-image-batching' or 'dflash-image'" exit 1 fi fi @@ -707,23 +707,17 @@ if [ "$MODEL_NAME" = "muse_glimmer" ]; then --backend cuda \ --output-dir "${OUTPUT_DIR}" ;; - solo-text-batching) - # The off-graph cache exports the batching methods CudaExecutor drives: - # Static forward_{N}, dynamic forward_others and the device samplers. - EXPORT_START_SECONDS=$SECONDS - TMPDIR="$INDUCTOR_TMPDIR" \ - TORCHINDUCTOR_CACHE_DIR="$INDUCTOR_CACHE" \ - python -m executorch.examples.models.muse_glimmer.export.export_solo \ - --gguf "$TARGET_GGUF_PATH" \ - --backend cuda \ - --use-offgraph-kv-cache \ - --output-dir "${OUTPUT_DIR}" - ;; - dflash-image) + dflash-image|dflash-image-batching) DRAFT_GGUF_FILE="dflash-Muse-Glimmer-30B-Q4_K_M.gguf" MMPROJ_GGUF_FILE="mmproj-Muse-Glimmer-30B-Q4_K_M.gguf" python -c "from huggingface_hub import hf_hub_download; hf_hub_download('${HF_MODEL}', '${DRAFT_GGUF_FILE}', local_dir='${LOCAL_MODEL_DIR}')" python -c "from huggingface_hub import hf_hub_download; hf_hub_download('${HF_MODEL}', '${MMPROJ_GGUF_FILE}', local_dir='${LOCAL_MODEL_DIR}')" + # The off-graph cache exports the batching methods the CUDA DFlash + # executor drives, plus the vision encoder and forward_from_embeddings. + OFFGRAPH_FLAG="" + if [ "$MODE" = "dflash-image-batching" ]; then + OFFGRAPH_FLAG="--use-offgraph-kv-cache" + fi EXPORT_START_SECONDS=$SECONDS TMPDIR="$INDUCTOR_TMPDIR" \ TORCHINDUCTOR_CACHE_DIR="$INDUCTOR_CACHE" \ @@ -732,10 +726,11 @@ if [ "$MODEL_NAME" = "muse_glimmer" ]; then --draft-gguf "${LOCAL_MODEL_DIR}/${DRAFT_GGUF_FILE}" \ --mmproj "${LOCAL_MODEL_DIR}/${MMPROJ_GGUF_FILE}" \ --backend cuda \ + $OFFGRAPH_FLAG \ --output-dir "${OUTPUT_DIR}" ;; *) - echo "Error: Muse Glimmer requires mode 'solo-text', 'solo-text-batching' or 'dflash-image'" + echo "Error: Muse Glimmer requires mode 'solo-text', 'dflash-image-batching' or 'dflash-image'" exit 1 ;; esac @@ -746,7 +741,7 @@ if [ "$MODEL_NAME" = "muse_glimmer" ]; then test -f "${OUTPUT_DIR}/model.pte" test -f "${OUTPUT_DIR}/aoti_cuda_blob.ptd" - if [ "$MODE" = "dflash-image" ]; then + if [ "$MODE" = "dflash-image" ] || [ "$MODE" = "dflash-image-batching" ]; then test -f "${OUTPUT_DIR}/pos_embed.bin" fi ls -al "${OUTPUT_DIR}" diff --git a/.ci/scripts/muse_glimmer_batching_smoke.cpp b/.ci/scripts/muse_glimmer_batching_smoke.cpp index 6b9c794c706..12e821ea696 100644 --- a/.ci/scripts/muse_glimmer_batching_smoke.cpp +++ b/.ci/scripts/muse_glimmer_batching_smoke.cpp @@ -20,6 +20,7 @@ DECLARE_string(pte); DECLARE_string(tokenizer); DECLARE_string(data_path); DECLARE_string(backend); +DECLARE_bool(dflash); DECLARE_int32(max_sessions); DECLARE_int32(max_session_tokens); DECLARE_uint64(bos_id); @@ -64,6 +65,7 @@ int main(int argc, char** argv) { config.max_sessions = FLAGS_max_sessions; config.max_session_tokens = FLAGS_max_session_tokens; config.bos_id = FLAGS_bos_id; + config.use_dflash = FLAGS_dflash; auto backend = llm::create_muse_glimmer_backend(config); if (!backend.ok()) { throw std::runtime_error( diff --git a/.ci/scripts/test_model_e2e.sh b/.ci/scripts/test_model_e2e.sh index 786cd81f9e2..70b8f3855e7 100755 --- a/.ci/scripts/test_model_e2e.sh +++ b/.ci/scripts/test_model_e2e.sh @@ -51,7 +51,7 @@ Arguments: - vr-streaming: Voxtral Realtime streaming mode - vr-offline: Voxtral Realtime offline mode - solo-text: Muse Glimmer solo text mode - - solo-text-batching: Muse Glimmer text on the CUDA batching runtime + - dflash-image-batching: Muse Glimmer DFlash text and image on the CUDA batching runtime - dflash-image: Muse Glimmer DFlash vision mode Environment: @@ -126,7 +126,7 @@ if [ -n "$MODE" ]; then exit 1 fi ;; - solo-text|solo-text-batching|dflash-image) + solo-text|dflash-image-batching|dflash-image) if [ "$HF_MODEL" != "meta-models/Muse-Glimmer-30B-GGUF" ]; then echo "Error: Mode '$MODE' can only be used with Muse Glimmer model" echo "Provided model: $HF_MODEL" @@ -135,7 +135,7 @@ if [ -n "$MODE" ]; then ;; *) echo "Error: Unsupported mode '$MODE'" - echo "Supported modes: vr-streaming, vr-offline, solo-text, solo-text-batching, dflash-image" + echo "Supported modes: vr-streaming, vr-offline, solo-text, dflash-image-batching, dflash-image" exit 1 ;; esac @@ -321,12 +321,12 @@ case "$HF_MODEL" in EXPECTED_OUTPUT="Paris" IMAGE_URL="" ;; - solo-text-batching) + dflash-image-batching) # Built by `make muse-glimmer-batching-cuda` into its own directory. RUNNER_PATH="muse-glimmer-batching" RUNNER_TARGET="muse_glimmer_batching_runner" - EXPECTED_OUTPUT="Paris" - IMAGE_URL="" + EXPECTED_OUTPUT="dog" + IMAGE_URL="https://github.com/pytorch/hub/raw/master/images/dog.jpg" ;; dflash-image) RUNNER_TARGET="dflash_runner" @@ -334,7 +334,7 @@ case "$HF_MODEL" in IMAGE_URL="https://github.com/pytorch/hub/raw/master/images/dog.jpg" ;; *) - echo "Error: Muse Glimmer requires mode 'solo-text', 'solo-text-batching' or 'dflash-image'" + echo "Error: Muse Glimmer requires mode 'solo-text', 'dflash-image-batching' or 'dflash-image'" exit 1 ;; esac @@ -500,12 +500,12 @@ EOF fi RUNNER_ARGS="$RUNNER_ARGS --tokenizer_path ${MODEL_DIR}/$TOKENIZER_FILE --prompt_file \"$PROMPT_FILE\" --max_new_tokens 512" RUNNER_ARGS="$RUNNER_ARGS --cuda_graph" - if [ "$MODE" = "solo-text-batching" ]; then - # The batching runner goes through ServingRuntime and takes its own flags. - printf '%s' '<|start|>user<|message|>What is the capital of France?<|eot|><|start|>assistant' > "$PROMPT_FILE" - RUNNER_ARGS="--pte ${MODEL_DIR}/model.pte --data_path ${MODEL_DIR}/aoti_cuda_blob.ptd --tokenizer ${MODEL_DIR}/$TOKENIZER_FILE --prompt \"\$(cat $PROMPT_FILE)\" --max_new_tokens 128 --temperature 0 --max_session_tokens 4096" + if [ "$MODE" = "dflash-image-batching" ]; then + # The batching runner goes through ServingRuntime and takes its own flags; + # pos_embed.bin is read from beside model.pte. + RUNNER_ARGS="--pte ${MODEL_DIR}/model.pte --data_path ${MODEL_DIR}/aoti_cuda_blob.ptd --tokenizer ${MODEL_DIR}/$TOKENIZER_FILE --prompt \"\$(cat $PROMPT_FILE)\" --max_new_tokens 512 --temperature 0 --max_session_tokens 4096 --dflash" fi - if [ "$MODE" = "dflash-image" ]; then + if [ "$MODE" = "dflash-image" ] || [ "$MODE" = "dflash-image-batching" ]; then RUNNER_ARGS="$RUNNER_ARGS --image_path ${MODEL_DIR}/test_image.jpg" fi ;; @@ -607,14 +607,15 @@ else fi echo "::endgroup::" -if [ "$DEVICE" = "cuda" ] && [ "$MODEL_NAME" = "muse_glimmer" ] && [ "$MODE" = "solo-text-batching" ]; then +if [ "$DEVICE" = "cuda" ] && [ "$MODEL_NAME" = "muse_glimmer" ] && [ "$MODE" = "dflash-image-batching" ]; then echo "::group::Run $MODEL_NAME batching serving smoke" # The CI-only correctness tool is not part of the normal serving build. bash .ci/scripts/build_muse_glimmer_batching_smoke.sh - # Concurrent staggered requests through ServingRuntime on CudaExecutor. + # Concurrent staggered requests through ServingRuntime on the DFlash executor. cmake-out/examples/models/muse-glimmer-batching/muse_glimmer_batching_smoke \ --pte "${MODEL_DIR}/model.pte" --data_path "${MODEL_DIR}/aoti_cuda_blob.ptd" \ --tokenizer "${MODEL_DIR}/$TOKENIZER_FILE" --max_sessions 8 --max_session_tokens 2048 \ + --dflash \ --mode requests --requests 5 --prompt_tokens 600 --max_new_tokens 64 --stagger_ms 200 pip install -r examples/llm_server/python/requirements.txt @@ -639,6 +640,7 @@ PY --hf-tokenizer "${MODEL_DIR}" \ --max-context 4096 \ --prefix-cache-entries 2 \ + --dflash \ --host 127.0.0.1 \ --port "$PORT" >"$SERVER_LOG" 2>&1 & SERVER_PID=$! @@ -651,13 +653,15 @@ PY } trap cleanup_mg_server EXIT - if ! python - "$PORT" "$SERVER_LOG" <<'PY' + if ! python - "$PORT" "$SERVER_LOG" "${MODEL_DIR}/test_image.jpg" <<'PY' +import base64 import json import sys import time import urllib.request +from concurrent.futures import ThreadPoolExecutor -port, log_path = sys.argv[1], sys.argv[2] +port, log_path, image_path = sys.argv[1], sys.argv[2], sys.argv[3] base = f"http://127.0.0.1:{port}" @@ -711,6 +715,38 @@ for line in stream.splitlines(): pieces.append((delta.get("content") or "") + (delta.get("reasoning_content") or "")) if len([p for p in pieces if p]) < 2 or "Paris" not in "".join(pieces): raise AssertionError(f"expected Paris streamed in several chunks, got: {pieces!r}") + +with open(image_path, "rb") as f: + image_url = "data:image/jpeg;base64," + base64.b64encode(f.read()).decode("ascii") +image_body = { + "model": "muse_glimmer", + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "What animal is in this image?"}, + {"type": "image_url", "image_url": {"url": image_url}}, + ], + } + ], + "max_tokens": 512, + "temperature": 0, +} + + +def answer(payload): + message = json.loads(request("/v1/chat/completions", payload))["choices"][0]["message"] + return (message.get("content") or "") + (message.get("reasoning_content") or "") + + +# Image and text requests in flight together share DFlash executor steps. +with ThreadPoolExecutor(max_workers=3) as pool: + image_a, text, image_b = pool.map(answer, (image_body, body, image_body)) +for reply in (image_a, image_b): + if "dog" not in reply.lower(): + raise AssertionError(f"expected dog in image response, got: {reply!r}") +if "Paris" not in text: + raise AssertionError(f"expected Paris beside image requests, got: {text!r}") print("Muse Glimmer CUDA batching serving smoke passed") PY then diff --git a/.github/workflows/cuda.yml b/.github/workflows/cuda.yml index 525eef539fc..08fc7cd1422 100644 --- a/.github/workflows/cuda.yml +++ b/.github/workflows/cuda.yml @@ -1222,7 +1222,7 @@ jobs: - variant: kquant-17gb mode: solo-text - variant: kquant-17gb - mode: solo-text-batching + mode: dflash-image-batching - variant: kquant-17gb mode: dflash-image - variant: kquant-dynamic diff --git a/examples/models/muse-glimmer/BATCHING_SERVING.md b/examples/models/muse-glimmer/BATCHING_SERVING.md index 90254af2722..52dfc2969a8 100644 --- a/examples/models/muse-glimmer/BATCHING_SERVING.md +++ b/examples/models/muse-glimmer/BATCHING_SERVING.md @@ -4,7 +4,7 @@ The batching runner and multiplexed worker support MLX and CUDA autoregressive generation with text only or text plus one image across the submitted conversation. They are opt-in entry points; the legacy runners and export defaults remain unchanged. DFlash batching runs on MLX -and CUDA (text only). +(text only) and CUDA, which also serves images when exported with `--mmproj`. CUDA specifics are in [CUDA](#cuda) below. ## Export diff --git a/examples/models/muse-glimmer/export/export_dflash.py b/examples/models/muse-glimmer/export/export_dflash.py index 479b8a033a8..6c2eb2da01d 100644 --- a/examples/models/muse-glimmer/export/export_dflash.py +++ b/examples/models/muse-glimmer/export/export_dflash.py @@ -261,13 +261,6 @@ def export_dflash( activation_dtype=activation_dtype, ) - if ( - draft_model is not None - and use_offgraph_kv_cache - and backend == "cuda" - and vision_model is not None - ): - raise ValueError("the CUDA batching DFlash export does not support vision yet") if use_offgraph_kv_cache and backend == "cuda": from executorch.examples.models.muse_glimmer.export.export_solo import ( DEFAULT_FORWARD_SIZES, diff --git a/examples/models/muse-glimmer/runtime/cuda_dflash_executor.cpp b/examples/models/muse-glimmer/runtime/cuda_dflash_executor.cpp index 68ae07d6f2a..b79b28be429 100644 --- a/examples/models/muse-glimmer/runtime/cuda_dflash_executor.cpp +++ b/examples/models/muse-glimmer/runtime/cuda_dflash_executor.cpp @@ -561,10 +561,9 @@ Result MuseGlimmerCudaDFlashExecutor::create( ET_CHECK_OR_RETURN_ERROR( names.count(df::kForward) && names.count(df::kVerify) && names.count(df::kVerifyArgmax) && - !names.count("get_mutable_buffer_metadata") && - !names.count("vision_encoder"), + !names.count("get_mutable_buffer_metadata"), InvalidProgram, - "CUDA DFlash requires a tapped text-only off-graph artifact with its verifiers"); + "CUDA DFlash requires a tapped off-graph artifact with its verifiers"); ET_ASSIGN_OR_RETURN(block, positive_int(module, df::kBlock)); ET_ASSIGN_OR_RETURN( declared_block, positive_int(module, "get_draft_dflash_block_size")); @@ -690,19 +689,11 @@ Result MuseGlimmerCudaDFlashExecutor::create( config.bos_id < static_cast(target->vocab_size()), InvalidProgram, "Muse Glimmer DFlash vocabulary cannot hold its special tokens"); - auto spec = std::make_shared( - MuseGlimmerPreparationSpec{ - aten::ScalarType::BFloat16, - 0, - max_context, - static_cast(target->preferred_batch_tokens()), - target->vocab_size(), - false, - 0, - config.image_limits, - config.bos_id}); - auto executor = - std::unique_ptr(new MuseGlimmerCudaDFlashExecutor( + std::shared_ptr embeddings; + ET_ASSIGN_OR_RETURN( + spec, make_cuda_preparation(*target, config, max_context, &embeddings)); + auto executor = std::unique_ptr( + new MuseGlimmerCudaDFlashExecutor( std::move(target), std::move(draft_cache), block, @@ -713,6 +704,7 @@ Result MuseGlimmerCudaDFlashExecutor::create( config.max_sessions, max_context, hidden_width)); + executor->embeddings_ = std::move(embeddings); return MuseGlimmerBackend{std::move(executor), std::move(spec)}; } @@ -1390,6 +1382,11 @@ bool MuseGlimmerCudaDFlashExecutor::fail_batch( return false; } +bool MuseGlimmerCudaDFlashExecutor::accepts( + const batching::PreparedInput& input) const { + return embeddings_ && embeddings_->accepts(input); +} + bool MuseGlimmerCudaDFlashExecutor::execute( const batching::BatchInput& batch, batching::BatchOutput& out) { @@ -1409,14 +1406,21 @@ bool MuseGlimmerCudaDFlashExecutor::execute( std::unordered_map cursor; std::vector> rewinds; bool pending_decode = !batch.inputs.empty(); + bool prepared_step = false; for (const auto& input : batch.inputs) { const auto it = sessions_.find(input.sid); const auto* payload = std::get_if(&input.payload); - if (it == sessions_.end() || it->second.poisoned || !payload || - !*payload || input.size == 0 || input.offset > (*payload)->size() || - input.size > (*payload)->size() - input.offset || - input.position < 0 || + const auto* prepared = + std::get_if(&input.payload); + const bool tokens = payload && *payload; + const bool image = prepared && *prepared && accepts(**prepared); + const std::size_t available = tokens ? (*payload)->size() + : image ? (*prepared)->size() + : 0; + if (it == sessions_.end() || it->second.poisoned || (!tokens && !image) || + input.size == 0 || input.offset > available || + input.size > available - input.offset || input.position < 0 || input.offset > static_cast(max_context_) || input.size > static_cast(max_context_) || (input.produce_output && !it->second.sampling) || @@ -1447,13 +1451,16 @@ bool MuseGlimmerCudaDFlashExecutor::execute( // A one-token prompt/replay is not necessarily emitted decode feedback. // Stay on the legacy path unless every owner has an exact pending seed, // one input, and room for the complete trained draft block. - pending_decode = pending_decode && first && input.produce_output && - input.size == 1 && start == it->second.frontier && - start <= max_context_ - block_ && it->second.pending && + pending_decode = pending_decode && tokens && first && + input.produce_output && input.size == 1 && + start == it->second.frontier && start <= max_context_ - block_ && + it->second.pending && *it->second.pending == (**payload)[input.offset]; + prepared_step = prepared_step || image; at->second = static_cast(end); for (std::size_t j = 0; j < input.size; ++j) { - const auto token = (**payload)[input.offset + j]; + // Prepared rows reach the target as embeddings, not tokens. + const Token token = tokens ? (**payload)[input.offset + j] : 0; if (token >= static_cast(target_->vocab_size())) { return reject_batch(); } @@ -1467,6 +1474,15 @@ bool MuseGlimmerCudaDFlashExecutor::execute( ? static_cast(step.tokens.size()) - 1 : -1); } + if (prepared_step) { + auto embeddings = + embeddings_->materialize(target_->module(), batch.inputs); + if (!embeddings.ok()) { + ET_LOG(Error, "CUDA DFlash: could not embed the prepared inputs"); + return reject_batch(); + } + step.embeddings = std::move(*embeddings); + } // All inputs are validated before either cache is truncated. for (const auto& item : rewinds) { if (!rewind(sessions_.at(item.first), item.second)) { diff --git a/examples/models/muse-glimmer/runtime/cuda_dflash_executor.h b/examples/models/muse-glimmer/runtime/cuda_dflash_executor.h index 481fdd2a55d..5614d43e806 100644 --- a/examples/models/muse-glimmer/runtime/cuda_dflash_executor.h +++ b/examples/models/muse-glimmer/runtime/cuda_dflash_executor.h @@ -172,7 +172,9 @@ struct CudaDFlashStats { std::uint64_t failed_batches = 0; }; -// Text-only model adapter over the existing CUDA target executor. Decode drafts +// Model adapter over the existing CUDA target executor. Image prompts prefill +// through the target's embeddings forward; their rows' taps then feed the +// draft like any other context. Decode drafts // run the static draft_forward_{N} in CUDA graphs, as the target's static // methods do; longer contexts drain through the dynamic draft_forward. Drafts // are greedy; dflash_verify accepts or resamples them under each session's @@ -199,6 +201,7 @@ class MuseGlimmerCudaDFlashExecutor final : public batching::Executor { batching::SessionId session, const batching::SamplingParams& params, std::optional seed) override; + bool accepts(const batching::PreparedInput& input) const override; bool execute(const batching::BatchInput& batch, batching::BatchOutput& out) override; // Engine-thread-only, between executes. Counters measure actual adapter work. @@ -276,6 +279,8 @@ class MuseGlimmerCudaDFlashExecutor final : public batching::Executor { std::size_t row_bytes_; batching::SessionId next_session_ = 1; std::unordered_map sessions_; + // Null for a text-only program. + std::shared_ptr embeddings_; CudaDFlashStats stats_; cuda_dflash_detail::HiddenSnapshot verification_hidden_; cuda_dflash_detail::DeviceStaging verify_inputs_; From 818f25f2cd461649f751905767e54bf9479f5665 Mon Sep 17 00:00:00 2001 From: Oscar Andersson <87121123+oscarandersson8218@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:03:34 +0100 Subject: [PATCH 090/128] Arm backend: Materialize symbolic input dims (#24613) Add early pass that inserts `aten.sym_size.int` producers for symbolic tensor placeholder dimensions. This gives later shape-lowering passes graph producers for dynamic input sizes instead of relying on raw SymInt metadata. Some exported dynamic shapes represent placeholder dimensions as scaled expressions, such as `16*s0`, while downstream passes may need to materialize expressions over the base symbol, such as `(s0 - 1) % 2`. `Graph.materialize_symints()` cannot recover a producer for that base symbol from the scaled placeholder dimension alone. Extend `materialize_symints()` helper that first delegates to `Graph.materialize_symints()` and then falls back to deriving missing base symbols from symbolic placeholder dimensions. The fallback reuses or creates `aten.sym_size.int` producers and derives single-symbol multiples with shape arithmetic, for example `sym_size(x, dim) // 16`. Use the shared helper from Arm passes that materialize symbolic shape args, including conv padding, linear/view decomposition, select/slice decomposition, size-adjust slicing, high-rank permute rewrites, and canonicalized view rewrites. cc @digantdesai @freddan80 @per @zingo @mansnils @Sebastian-Larsson @robell @rascani Signed-off-by: Oscar Andersson Co-authored-by: Per Held --- backends/arm/_passes/__init__.py | 3 + backends/arm/_passes/arm_pass_manager.py | 2 + backends/arm/_passes/decompose_linear_pass.py | 14 +- backends/arm/_passes/decompose_select.py | 5 +- ...insert_sym_size_int_for_input_dims_pass.py | 77 +++++ .../normalize_delegate_io_layout_pass.py | 7 +- backends/arm/_passes/rewrite_conv_pass.py | 3 +- .../arm/_passes/size_adjust_input_pass.py | 3 +- ...insert_sym_size_int_for_input_dims_pass.py | 145 +++++++++ .../arm/test/passes/test_rewrite_conv_pass.py | 43 +++ backends/transforms/symbolic_shape_utils.py | 279 +++++++++++++++++- 11 files changed, 567 insertions(+), 14 deletions(-) create mode 100644 backends/arm/_passes/insert_sym_size_int_for_input_dims_pass.py create mode 100644 backends/arm/test/passes/test_insert_sym_size_int_for_input_dims_pass.py diff --git a/backends/arm/_passes/__init__.py b/backends/arm/_passes/__init__.py index 8ece3e350eb..3820c6b7fc4 100644 --- a/backends/arm/_passes/__init__.py +++ b/backends/arm/_passes/__init__.py @@ -170,6 +170,9 @@ InsertRescaleInt32Pass, InsertRescalePass, ) +from .insert_sym_size_int_for_input_dims_pass import ( # noqa + InsertSymSizeIntForInputDimsPass, +) from .insert_table_ops import InsertTableOpsPass # noqa from .lower_dynamic_w8a8_linear_pass import LowerDynamicW8A8LinearPass # noqa from .lower_int16_rsqrt_pass import LowerInt16RsqrtPass # noqa diff --git a/backends/arm/_passes/arm_pass_manager.py b/backends/arm/_passes/arm_pass_manager.py index f05f006a295..43a72734251 100644 --- a/backends/arm/_passes/arm_pass_manager.py +++ b/backends/arm/_passes/arm_pass_manager.py @@ -144,6 +144,7 @@ InsertInt32CastsAfterInt64PlaceholdersPass, InsertRescaleInt32Pass, InsertRescalePass, + InsertSymSizeIntForInputDimsPass, InsertTableOpsPass, LowerDynamicW8A8LinearPass, LowerInt16RsqrtPass, @@ -546,6 +547,7 @@ def _tosa_pipeline( self.add_passes( [ NormalizeDelegateIOLayoutPass(exported_program), + InsertSymSizeIntForInputDimsPass(), FuseQuantizedActivationPass(), RewriteBoolToFp32CastViaInt8Pass(), DecomposeTopKPass(self.tosa_spec), diff --git a/backends/arm/_passes/decompose_linear_pass.py b/backends/arm/_passes/decompose_linear_pass.py index 55cb690d9c1..18cc0246e22 100644 --- a/backends/arm/_passes/decompose_linear_pass.py +++ b/backends/arm/_passes/decompose_linear_pass.py @@ -14,6 +14,7 @@ get_first_fake_tensor, ) from executorch.backends.arm._passes.insert_rescales_pass import InsertRescaleInt32Pass +from executorch.backends.transforms.symbolic_shape_utils import materialize_symints from executorch.exir.dialects._ops import ops as exir_ops from executorch.exir.pass_base import ExportPass, PassResult @@ -58,19 +59,24 @@ def call(self, graph_module): # weights have shape (Co, Ci) weights_reshaped_shape = [weights_shape[0], weights_shape[1], 1, 1] - # Materialize SymInts into FX nodes; one call shares - # the symbol subgraph across all three shape lists. + # ``Graph.create_node`` rejects raw SymInts in call_function + # args; the shared ``materialize_symints`` helper walks each value's sympy + # expression and emits the FX subgraph that recomputes it from + # existing producers (placeholders / sym ops). One call shares + # the symbol→Proxy hash-cons across all three shape lists so + # repeated symbols become a single subgraph. with graph_module.graph.inserting_before(node): n_in, n_w = ( len(input_reshaped_shape), len(weights_reshaped_shape), ) - all_sizes = graph_module.graph.materialize_symints( + all_sizes = materialize_symints( + graph_module.graph, [ *input_reshaped_shape, *weights_reshaped_shape, *output_shape, - ] + ], ) input_reshaped_shape = all_sizes[:n_in] weights_reshaped_shape = all_sizes[n_in : n_in + n_w] diff --git a/backends/arm/_passes/decompose_select.py b/backends/arm/_passes/decompose_select.py index d4257ddca17..3a5db088b6c 100644 --- a/backends/arm/_passes/decompose_select.py +++ b/backends/arm/_passes/decompose_select.py @@ -16,6 +16,7 @@ from executorch.backends.arm._passes.convert_squeezes_to_view import ( ConvertSqueezesToViewPass, ) +from executorch.backends.transforms.symbolic_shape_utils import materialize_symints from executorch.exir.dialects._ops import ops as exir_ops from executorch.exir.pass_base import ExportPass, PassResult @@ -61,8 +62,8 @@ def call(self, graph_module: torch.fx.GraphModule): # ``index + 1`` (from negative-index wrap-around against a # dynamic shape) into graph nodes in a single call (shares # producer-discovery + hash-cons). Static ints pass through. - start_arg, end_arg = graph_module.graph.materialize_symints( - [index, index + 1] + start_arg, end_arg = materialize_symints( + graph_module.graph, [index, index + 1] ) slice_node = create_node( graph_module.graph, diff --git a/backends/arm/_passes/insert_sym_size_int_for_input_dims_pass.py b/backends/arm/_passes/insert_sym_size_int_for_input_dims_pass.py new file mode 100644 index 00000000000..1848d5fd1d5 --- /dev/null +++ b/backends/arm/_passes/insert_sym_size_int_for_input_dims_pass.py @@ -0,0 +1,77 @@ +# Copyright 2026 Arm Limited and/or its affiliates. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +from typing import Set, Type + +import torch +from executorch.backends.arm._passes import ArmPass +from executorch.backends.arm._passes.symbolic_to_tosa_shape_pass import ( + SymbolicToTosaShapesPass, +) +from executorch.exir.pass_base import ExportPass, PassResult + + +class InsertSymSizeIntForInputDimsPass(ArmPass): + _passes_required_after: Set[Type[ExportPass]] = {SymbolicToTosaShapesPass} + + def _get_tensor_meta(self, node: torch.fx.Node) -> torch.Tensor | None: + for meta_key in ("val", "example_value"): + meta_value = node.meta.get(meta_key) + if isinstance(meta_value, torch.Tensor): + return meta_value + return None + + def _has_sym_size_int( + self, graph: torch.fx.Graph, placeholder: torch.fx.Node, dim: int + ) -> bool: + for node in graph.nodes: + if node.op not in ("placeholder", "call_function"): + break + if ( + node.op == "call_function" + and node.target != torch.ops.aten.sym_size.int + ): + break + if node.op != "call_function": + continue + if node.args == (placeholder, dim): + return True + return False + + def call(self, graph_module: torch.fx.GraphModule) -> PassResult: + graph = graph_module.graph + modified = False + use_example_value = any("example_value" in node.meta for node in graph.nodes) + + for node in list(graph.nodes): + if node.op != "placeholder": + continue + + tensor = self._get_tensor_meta(node) + if tensor is None: + continue + + if use_example_value and "example_value" not in node.meta: + node.meta["example_value"] = tensor + + for dim, dim_value in enumerate(tensor.shape): + if not isinstance(dim_value, torch.SymInt): + continue + if self._has_sym_size_int(graph, node, dim): + continue + + with graph.inserting_after(node): + sym_size_node = graph.call_function( + torch.ops.aten.sym_size.int, (node, dim) + ) + sym_size_node.meta["val"] = dim_value + if use_example_value: + sym_size_node.meta["example_value"] = dim_value + modified = True + + if modified: + graph_module.recompile() + + return PassResult(graph_module, modified) diff --git a/backends/arm/_passes/normalize_delegate_io_layout_pass.py b/backends/arm/_passes/normalize_delegate_io_layout_pass.py index c55eec5c851..9850e61d8cb 100644 --- a/backends/arm/_passes/normalize_delegate_io_layout_pass.py +++ b/backends/arm/_passes/normalize_delegate_io_layout_pass.py @@ -13,6 +13,9 @@ is_param_node, permute_fake_tensor_metadata, ) +from executorch.backends.arm._passes.insert_sym_size_int_for_input_dims_pass import ( + InsertSymSizeIntForInputDimsPass, +) from executorch.exir import ExportedProgram from executorch.exir.dialects._ops import ops as exir_ops from executorch.exir.pass_base import ExportPass, PassResult @@ -21,7 +24,9 @@ class NormalizeDelegateIOLayoutPass(ArmPass): """Adjust delegated boundary tensor shapes and insert permutes at I/O.""" - _passes_required_after: Set[Type[ExportPass]] = set() + _passes_required_after: Set[Type[ExportPass]] = { + InsertSymSizeIntForInputDimsPass, + } def __init__(self, exported_program: ExportedProgram, *args, **kwargs) -> None: super().__init__(*args, **kwargs) diff --git a/backends/arm/_passes/rewrite_conv_pass.py b/backends/arm/_passes/rewrite_conv_pass.py index 1ed60228582..60ce9a0bb39 100644 --- a/backends/arm/_passes/rewrite_conv_pass.py +++ b/backends/arm/_passes/rewrite_conv_pass.py @@ -45,6 +45,7 @@ build_node_signature, DO_NOT_FUSE_DUPLICATE_META_KEY, ) +from executorch.backends.transforms.symbolic_shape_utils import materialize_symints from executorch.backends.transforms.utils import create_constant_placeholder from executorch.exir.dialects._ops import ops as exir_ops from executorch.exir.dialects.edge._ops import EdgeOpOverload @@ -1167,7 +1168,7 @@ def call(self, graph_module: torch.fx.GraphModule) -> PassResult: # noqa: C901 isinstance(p, torch.SymInt) for p in pad ): with graph_module.graph.inserting_before(node): - materialized_pad = graph_module.graph.materialize_symints(pad) + materialized_pad = materialize_symints(graph_module.graph, pad) new_conv_args = list(conv_args) new_conv_args[4] = materialized_pad conv_args = tuple(new_conv_args) diff --git a/backends/arm/_passes/size_adjust_input_pass.py b/backends/arm/_passes/size_adjust_input_pass.py index 1f71b2d046e..e26d12e8660 100644 --- a/backends/arm/_passes/size_adjust_input_pass.py +++ b/backends/arm/_passes/size_adjust_input_pass.py @@ -20,6 +20,7 @@ adjust_pooling_pad_if_needed, ) from executorch.backends.arm.tosa.specification import get_context_shape_env +from executorch.backends.transforms.symbolic_shape_utils import materialize_symints from executorch.exir.dialects._ops import ops as exir_ops from executorch.exir.pass_base import ExportPass, PassResult @@ -383,7 +384,7 @@ def call(self, graph_module: torch.fx.GraphModule) -> PassResult: # symints with shared sub-expressions get hash-consed into # one subgraph. Plain ints pass through unchanged. flat = [a for args in slice_args for a in args] - materialized = iter(graph.materialize_symints(flat)) + materialized = iter(materialize_symints(graph, flat)) # Pop exactly len(args) values from the materialized iterator # and pack them back into a tuple -- regroups the flat list # of lifted values into the original (dim, start, end) shape. diff --git a/backends/arm/test/passes/test_insert_sym_size_int_for_input_dims_pass.py b/backends/arm/test/passes/test_insert_sym_size_int_for_input_dims_pass.py new file mode 100644 index 00000000000..a2fce916865 --- /dev/null +++ b/backends/arm/test/passes/test_insert_sym_size_int_for_input_dims_pass.py @@ -0,0 +1,145 @@ +# Copyright 2026 Arm Limited and/or its affiliates. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +from unittest.mock import patch + +import torch +from executorch.backends.arm._passes.insert_sym_size_int_for_input_dims_pass import ( + InsertSymSizeIntForInputDimsPass, +) +from executorch.backends.transforms.symbolic_shape_utils import materialize_symints +from torch.export import Dim, export + + +class AddOne(torch.nn.Module): + def forward(self, x: torch.Tensor) -> torch.Tensor: + return x + 1 + + +class AddTwo(torch.nn.Module): + def forward(self, x: torch.Tensor) -> torch.Tensor: + return x + 2 + + +def _dynamic_graph() -> tuple[torch.fx.GraphModule, torch.fx.Node]: + exported_program = export( + AddOne(), + (torch.randn(48, 3),), + dynamic_shapes={"x": {0: Dim("batch", min=2, max=6) * 16}}, + ) + graph_module = exported_program.graph_module + placeholder = next( + node for node in graph_module.graph.nodes if node.op == "placeholder" + ) + return graph_module, placeholder + + +def _shared_symbol_dynamic_graph() -> tuple[torch.fx.GraphModule, torch.fx.Node]: + batch = Dim("batch", min=2, max=6) + exported_program = export( + AddTwo(), + (torch.randn(48, 24),), + dynamic_shapes={"x": {0: batch * 16, 1: batch * 8}}, + ) + graph_module = exported_program.graph_module + placeholder = next( + node for node in graph_module.graph.nodes if node.op == "placeholder" + ) + return graph_module, placeholder + + +def test_insert_sym_size_ignores_later_matching_node() -> None: + graph_module, placeholder = _dynamic_graph() + graph = graph_module.graph + first_operator = next(node for node in graph.nodes if node.op == "call_function") + with graph.inserting_before(graph.output_node()): + later_sym_size = graph.call_function( + torch.ops.aten.sym_size.int, (placeholder, 0) + ) + + InsertSymSizeIntForInputDimsPass()(graph_module) + + matching_nodes = [ + node + for node in graph.nodes + if node.op == "call_function" + and node.target == torch.ops.aten.sym_size.int + and node.args == (placeholder, 0) + ] + node_order = list(graph.nodes) + assert len(matching_nodes) == 2 + assert node_order.index(matching_nodes[0]) < node_order.index(first_operator) + assert node_order.index(later_sym_size) > node_order.index(first_operator) + + +def test_materialize_symints_ignores_later_matching_node() -> None: + graph_module, placeholder = _dynamic_graph() + graph = graph_module.graph + first_operator = next(node for node in graph.nodes if node.op == "call_function") + dim = placeholder.meta["val"].shape[0] // 16 + with graph.inserting_before(graph.output_node()): + later_sym_size = graph.call_function( + torch.ops.aten.sym_size.int, (placeholder, 0) + ) + + with patch.object( + torch.fx.Graph, + "materialize_symints", + side_effect=AssertionError("materialize_symints: no producer for batch"), + ): + with graph.inserting_before(first_operator): + materialized = materialize_symints(graph, [dim]) + + graph.lint() + node_order = list(graph.nodes) + assert isinstance(materialized[0], torch.fx.Node) + assert node_order.index(materialized[0]) < node_order.index(first_operator) + assert node_order.index(later_sym_size) > node_order.index(first_operator) + + +def test_materialize_symints_reads_mixed_placeholder_metadata() -> None: + graph_module, placeholder = _dynamic_graph() + graph = graph_module.graph + first_operator = next(node for node in graph.nodes if node.op == "call_function") + dim = placeholder.meta["val"].shape[0] // 16 + with graph.inserting_before(placeholder): + other_placeholder = graph.placeholder("other") + other_placeholder.meta["example_value"] = torch.tensor(1) + + with patch.object( + torch.fx.Graph, + "materialize_symints", + side_effect=AssertionError("materialize_symints: no producer for batch"), + ): + with graph.inserting_before(first_operator): + materialized = materialize_symints(graph, [dim]) + + graph.lint() + assert isinstance(materialized[0], torch.fx.Node) + assert materialized[0].meta["val"] == dim + + +def test_materialize_symints_reuses_first_source_for_shared_symbol() -> None: + graph_module, placeholder = _shared_symbol_dynamic_graph() + graph = graph_module.graph + first_operator = next(node for node in graph.nodes if node.op == "call_function") + dim = placeholder.meta["val"].shape[0] // 16 + + with patch.object( + torch.fx.Graph, + "materialize_symints", + side_effect=AssertionError("materialize_symints: no producer for batch"), + ): + with graph.inserting_before(first_operator): + materialized = materialize_symints(graph, [dim]) + + graph.lint() + sym_size_nodes = [ + node + for node in graph.nodes + if node.op == "call_function" and node.target == torch.ops.aten.sym_size.int + ] + assert len(sym_size_nodes) == 1 + assert isinstance(materialized[0], torch.fx.Node) diff --git a/backends/arm/test/passes/test_rewrite_conv_pass.py b/backends/arm/test/passes/test_rewrite_conv_pass.py index 3928aa81e75..f1b42175dc0 100644 --- a/backends/arm/test/passes/test_rewrite_conv_pass.py +++ b/backends/arm/test/passes/test_rewrite_conv_pass.py @@ -4,8 +4,10 @@ # LICENSE file in the root directory of this source tree. import itertools +import operator import os from typing import Any, cast +from unittest.mock import patch import pytest import torch @@ -42,6 +44,7 @@ from executorch.backends.transforms.fuse_duplicate_users_pass import ( build_node_signature, ) +from executorch.backends.transforms.symbolic_shape_utils import materialize_symints from executorch.exir import EdgeCompileConfig, to_edge, to_edge_transform_and_lower from executorch.exir.dialects._ops import ops as exir_ops from torch.export import Dim, export @@ -972,6 +975,46 @@ def test_rewrite_conv_int16_bias_lowers_to_single_tosa_conv( assert bias_node.meta.get(TosaSpecialDtype.meta_key()) == TosaSpecialDtype.INT48 +def test_rewrite_conv_materializes_padding_from_scaled_input_symbol(): + model = ConvModule() + example_inputs = (torch.randn(1, 3, 48, 48),) + ep = export( + model, + example_inputs, + dynamic_shapes={ + "x": { + 2: Dim("height", min=2, max=6) * 16, + 3: Dim("width", min=2, max=6) * 16, + } + }, + ) + edge_model = to_edge(ep) + graph = edge_model.exported_program().graph + x = next( + node for node in graph.nodes if node.op == "placeholder" and node.name == "x" + ) + height = x.meta["val"].shape[2] // 16 + pad = 1 - ((height - 1) % 2) + + with patch.object( + torch.fx.Graph, + "materialize_symints", + side_effect=AssertionError("materialize_symints: no producer for height"), + ): + with graph.inserting_before( + next(node for node in graph.nodes if node.op == "output") + ): + materialized = materialize_symints(graph, [pad]) + + edge_model.exported_program().graph_module.graph.lint() + assert len(materialized) == 1 + assert isinstance(materialized[0], torch.fx.Node) + targets = {node.target for node in graph.nodes if node.op == "call_function"} + assert torch.ops.aten.sym_size.int in targets + assert operator.floordiv in targets + assert operator.mod in targets + + def test_rewrite_conv_dynamic_keeps_static_padding_when_symbolic_remainder_is_zero(): model = ConvModule() example_inputs = (torch.randn(1, 3, 9, 12),) diff --git a/backends/transforms/symbolic_shape_utils.py b/backends/transforms/symbolic_shape_utils.py index 1b3aa419c3b..a930b80c447 100644 --- a/backends/transforms/symbolic_shape_utils.py +++ b/backends/transforms/symbolic_shape_utils.py @@ -3,20 +3,289 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. -from collections.abc import Sequence +import operator +from typing import Any, NamedTuple, Sequence import sympy # type: ignore[import-untyped] import torch +from sympy.logic.boolalg import BooleanAtom # type: ignore[import-untyped] +from torch.fx.experimental.proxy_tensor import py_sym_types +from torch.utils._sympy.interp import _run_sympy_handler, sympy_interp +from torch.utils._sympy.reference import PythonReferenceAnalysis + + +class _SymSizeSource(NamedTuple): + placeholder: torch.fx.Node + dim: int + dim_value: torch.SymInt + symbol: sympy.Symbol + coefficient: int + + +def _expr_single_symbol_multiple(expr: sympy.Expr) -> tuple[sympy.Symbol, int] | None: + if isinstance(expr, sympy.Symbol): + return expr, 1 + + coefficient, factors = expr.as_coeff_mul() + if ( + len(factors) == 1 + and isinstance(factors[0], sympy.Symbol) + and coefficient.is_integer + ): + return factors[0], int(coefficient) + return None + + +def _node_value(node: torch.fx.Node) -> Any: + value = node.meta.get("val") + if value is not None: + return value + return node.meta.get("example_value") + + +def _set_node_value(node: torch.fx.Node, value: Any) -> None: + node.meta["val"] = value + # val is canonical. Mirror it to example_value when inputs use + # that metadata convention. + if any( + isinstance(arg, torch.fx.Node) and "example_value" in arg.meta + for arg in node.args + ): + node.meta["example_value"] = value + + +def _existing_sym_size_node_before( + graph: torch.fx.Graph, + placeholder: torch.fx.Node, + dim: int, + before_node: torch.fx.Node, +) -> torch.fx.Node | None: + for node in graph.nodes: + if node is before_node: + break + if ( + node.op == "call_function" + and node.target == torch.ops.aten.sym_size.int + and node.args == (placeholder, dim) + ): + return node + return None + + +def _needed_symbols(values: Sequence[int | torch.SymInt]) -> set[sympy.Symbol]: + return { + symbol + for value in values + if isinstance(value, torch.SymInt) + for symbol in value.node.expr.free_symbols + } + + +def _collect_sym_size_sources( + graph: torch.fx.Graph, + needed_symbols: set[sympy.Symbol], + tracer: torch.fx.proxy.GraphAppendingTracer, + expr_to_proxy: dict[sympy.Expr, torch.fx.Proxy], +) -> list[_SymSizeSource]: + sources: list[_SymSizeSource] = [] + for node in graph.nodes: + if node.op != "placeholder": + continue + + meta_value = _node_value(node) + if isinstance(meta_value, py_sym_types): + expr = meta_value.node.expr + if isinstance(expr, sympy.Symbol) and expr in needed_symbols: + expr_to_proxy.setdefault(expr, torch.fx.Proxy(node, tracer=tracer)) + continue + + if isinstance(meta_value, torch.Tensor): + sources.extend(_sources_from_tensor(node, meta_value, needed_symbols)) + return sources + + +def _sources_from_tensor( + node: torch.fx.Node, + tensor: torch.Tensor, + needed_symbols: set[sympy.Symbol], +) -> list[_SymSizeSource]: + sources: list[_SymSizeSource] = [] + for dim, dim_value in enumerate(tensor.shape): + if not isinstance(dim_value, torch.SymInt): + continue + source = _expr_single_symbol_multiple(dim_value.node.expr) + if source is None: + continue + symbol, coefficient = source + if symbol in needed_symbols: + sources.append(_SymSizeSource(node, dim, dim_value, symbol, coefficient)) + return sources + + +def _add_sym_size_source_proxies( + graph: torch.fx.Graph, + tracer: torch.fx.proxy.GraphAppendingTracer, + expr_to_proxy: dict[sympy.Expr, torch.fx.Proxy], + sources: list[_SymSizeSource], +) -> None: + for source in sources: + if source.symbol in expr_to_proxy: + continue + + inserted_sym_size_node = graph.call_function( + torch.ops.aten.sym_size.int, (source.placeholder, source.dim) + ) + sym_size_node = _existing_sym_size_node_before( + graph, + source.placeholder, + source.dim, + inserted_sym_size_node, + ) + if sym_size_node is None: + sym_size_node = inserted_sym_size_node + _set_node_value(sym_size_node, source.dim_value) + else: + graph.erase_node(inserted_sym_size_node) + + sym_size_proxy = torch.fx.Proxy(sym_size_node, tracer=tracer) + expr_to_proxy.setdefault(source.dim_value.node.expr, sym_size_proxy) + if source.coefficient == 1: + expr_to_proxy.setdefault(source.symbol, sym_size_proxy) + continue + + symbol_node = graph.call_function( + operator.floordiv, (sym_size_node, source.coefficient) + ) + _set_node_value(symbol_node, source.dim_value // source.coefficient) + expr_to_proxy.setdefault( + source.symbol, torch.fx.Proxy(symbol_node, tracer=tracer) + ) + + +def _arg_meta_value(arg: Any) -> Any: + if isinstance(arg, torch.fx.Node): + return _node_value(arg) + return arg + + +def _annotate_proxy_meta(result: torch.fx.Proxy) -> None: + target = result.node.target + if not callable(target): + return + try: + fake_args = tuple(_arg_meta_value(arg) for arg in result.node.args) + fake_kwargs = { + key: _arg_meta_value(value) for key, value in result.node.kwargs.items() + } + _set_node_value(result.node, target(*fake_args, **fake_kwargs)) + except (TypeError, ValueError, AttributeError): + pass + + +def _sympy_interp_cached( + expr: sympy.Expr, + expr_to_proxy: dict[sympy.Expr, torch.fx.Proxy], +) -> Any: + if expr in expr_to_proxy: + return expr_to_proxy[expr] + if isinstance(expr, (sympy.Integer, sympy.Number, sympy.Symbol, BooleanAtom)): + return sympy_interp(PythonReferenceAnalysis, expr_to_proxy, expr) + + result = _run_sympy_handler( + PythonReferenceAnalysis, + [_sympy_interp_cached(arg, expr_to_proxy) for arg in expr.args], + expr, + ) + if isinstance(result, torch.fx.Proxy): + expr_to_proxy[expr] = result + _annotate_proxy_meta(result) + return result + + +def _materialize_values( + values: Sequence[int | torch.SymInt], + expr_to_proxy: dict[sympy.Expr, torch.fx.Proxy], + original_error: AssertionError, +) -> list[torch.fx.Node | int]: + out: list[torch.fx.Node | int] = [] + for value in values: + if isinstance(value, int): + out.append(value) + continue + if not isinstance(value, torch.SymInt): + raise TypeError( + f"materialize_symints: expected SymInt or int, got " + f"{type(value).__name__}" + ) + out.append(_materialize_value(value, expr_to_proxy, original_error)) + return out + + +def _materialize_value( + value: torch.SymInt, + expr_to_proxy: dict[sympy.Expr, torch.fx.Proxy], + original_error: AssertionError, +) -> torch.fx.Node | int: + target_expr = value.node.expr + if target_expr.is_number: + return int(value) + missing_symbols = [ + symbol for symbol in target_expr.free_symbols if symbol not in expr_to_proxy + ] + if missing_symbols: + raise original_error + + result = _sympy_interp_cached(target_expr, expr_to_proxy) + if not isinstance(result, torch.fx.Proxy): + if not isinstance(result, (int, bool)): + raise AssertionError( + f"materialize_symints: non-Proxy result {result!r} for " + f"non-constant target {target_expr!r}" + ) + return int(result) + _set_node_value(result.node, value) + return result.node + + +def _materialize_from_placeholder_dims( + graph: torch.fx.Graph, + values: Sequence[int | torch.SymInt], + original_error: AssertionError, +) -> list[torch.fx.Node | int]: + needed_symbols = _needed_symbols(values) + tracer = torch.fx.proxy.GraphAppendingTracer(graph) + expr_to_proxy: dict[sympy.Expr, torch.fx.Proxy] = {} + sources = _collect_sym_size_sources(graph, needed_symbols, tracer, expr_to_proxy) + _add_sym_size_source_proxies(graph, tracer, expr_to_proxy, sources) + return _materialize_values(values, expr_to_proxy, original_error) def materialize_symints( graph: torch.fx.Graph, values: Sequence[int | torch.SymInt] ) -> list[torch.fx.Node | int]: - """Materialize symbolic integers into FX graph values.""" - materialized = graph.materialize_symints(values) - if any(isinstance(value, torch.SymInt) for value in materialized): + """Materialize symbolic integers into FX graph values. + + Falls back to deriving base symbols from scaled symbolic placeholder + dimensions, for example deriving ``s0`` from an input dim ``16*s0``. + + Args: + graph (torch.fx.Graph): Graph where producer nodes are inserted. + values (Sequence[int | torch.SymInt]): Integer values to materialize. + + Returns: + list[torch.fx.Node | int]: Materialized graph nodes or static integers. + + """ + try: + materialized = graph.materialize_symints(values) + if not any(isinstance(value, torch.SymInt) for value in materialized): + return materialized raise AssertionError("materialize_symints returned a raw SymInt") - return materialized + except AssertionError as error: + if "materialize_symints" not in str(error): + raise + + return _materialize_from_placeholder_dims(graph, values, error) def _shape_source_symbol(expr: sympy.Expr) -> sympy.Symbol | None: From 8f4bfc0058a35a251ad087fb6d00df98cc8531f6 Mon Sep 17 00:00:00 2001 From: George Gekov Date: Mon, 2 Nov 2026 16:53:04 +0000 Subject: [PATCH 091/128] Arm backend: Manage uint8 output tensor on shader for IFNetTwoBlock When exporting the model with --io-quantization uint8, we need to RESCALE from uint8 to int8 on the input tensors and RESCALE from int8 to uint8 on the output tensor. Currently, the output tensor, computed in the last shader, assumes int8 output. This PR adds support for uint8 output by rescaling the output from int8 to uint8. Signed-off-by: George Gekov Change-Id: I85b2f196fca8bdc64522f5cc2d64d8a8e3cb8ac7 --- .../QAT_example/rife_vgf/passes/rife_blend.py | 34 ++++++++--- .../rife_vgf/passes/test_rife_blend.py | 56 +++++++++++++++++-- .../rife_vgf/shaders/final_warps.glsl | 6 ++ .../rife_vgf/shaders/final_warps.py | 4 ++ 4 files changed, 85 insertions(+), 15 deletions(-) diff --git a/examples/arm/QAT_example/rife_vgf/passes/rife_blend.py b/examples/arm/QAT_example/rife_vgf/passes/rife_blend.py index b82a5ae724f..bb29dec227d 100644 --- a/examples/arm/QAT_example/rife_vgf/passes/rife_blend.py +++ b/examples/arm/QAT_example/rife_vgf/passes/rife_blend.py @@ -28,23 +28,29 @@ class RifeBlend: complement: torch.Tensor image_zero_points: tuple[int, int] mask_zero_points: tuple[int, int] + output_rescale: Node | None = None -def _rescale(node, dtype, nodes): +def _rescale(node, dtype, nodes, *, output_unsigned=False): + output_min, output_max = (0, 255) if output_unsigned else (-128, 127) if ( not _is_op(node, TOSA.RESCALE.default, 5) or node.args[1] != dtype or len(node.args[2]) != 1 or not math.isfinite(node.args[2][0]) or node.args[2][0] <= 0 - or any(node.kwargs.values()) - or not all( - type(node.args[i]) is int and -128 <= node.args[i] <= 127 for i in (3, 4) - ) + or node.kwargs.get("output_unsigned", False) != output_unsigned + or node.kwargs.get("input_unsigned", False) + or not all(type(value) is int for value in node.args[3:5]) + or not -128 <= node.args[3] <= 127 + or not output_min <= node.args[4] <= output_max + or (output_unsigned and node.args[0].meta["val"].dtype != torch.int8) or (dtype == torch.int32 and node.args[4] != 0) or (node.args[0].meta["val"].dtype == torch.int32 and node.args[3] != 0) ): - raise ValueError("Expected a signed per-tensor blend rescale.") + raise ValueError( + "Unsupported blend rescale dtype, scale, zero points or flags." + ) nodes.add(node) return node.args[0] @@ -57,7 +63,9 @@ def _apply_rescale(values, node, *, clamp=True): ) >> shifts[0] if not clamp: return scaled + node.args[4] - limits = torch.iinfo(node.args[1]) + limits = torch.iinfo( + torch.uint8 if node.kwargs.get("output_unsigned", False) else node.args[1] + ) return (scaled + node.args[4]).clamp(limits.min, limits.max) @@ -135,7 +143,14 @@ def match_rife_blend(program, warps, sigmoid): if _tensor_shape(result) != shape or result.meta["val"].dtype != torch.int8: raise ValueError("Expected an NCHW INT8 RGB result.") nodes = {*warps, sigmoid} - add = _rescale(result, torch.int8, nodes) + output_rescale = None + blend_result = result + if result.kwargs.get("output_unsigned", False): + blend_result = _rescale(result, torch.int8, nodes, output_unsigned=True) + if _tensor_shape(blend_result) != shape: + raise ValueError("Expected an NCHW INT8 blend before the output rescale.") + output_rescale = result + add = _rescale(blend_result, torch.int8, nodes) if not _is_op(add, TOSA.ADD.default, 2): raise ValueError("Expected the final weighted image addition.") nodes.add(add) @@ -156,8 +171,9 @@ def match_rife_blend(program, warps, sigmoid): raise ValueError("Weighted image addition could overflow INT32.") return RifeBlend( result, - (mul0, mul1, left, right, result), + (mul0, mul1, left, right, blend_result), complement, (image_zp0, image_zp1), (mask_zp0, mask_zp1), + output_rescale, ) diff --git a/examples/arm/QAT_example/rife_vgf/passes/test_rife_blend.py b/examples/arm/QAT_example/rife_vgf/passes/test_rife_blend.py index cc990007034..e369a6c0fb1 100644 --- a/examples/arm/QAT_example/rife_vgf/passes/test_rife_blend.py +++ b/examples/arm/QAT_example/rife_vgf/passes/test_rife_blend.py @@ -33,7 +33,7 @@ TOSA = fuse.TOSA -def _blend_program(height=4, width=6): +def _blend_program(height=4, width=6, *, output_unsigned=False): program, packed, phase, previous, delta, flow, sigmoid, old_blend = _program( height, width ) @@ -97,6 +97,9 @@ def rescale(source, dtype, scale, zp, out_zp=0): sides.append(rescale(weighted, torch.int32, add_scale, -127)) add = node(TOSA.ADD.default, tuple(sides), image_shape) result = rescale(add, torch.int8, 1.9043485036435921e-6, 0, -127) + if output_unsigned: + result = rescale(result, torch.int8, 1.0038925936873089, -127, 0) + result.kwargs = {"input_unsigned": False, "output_unsigned": True} graph.output((result,)) program.graph_signature.output_specs[:] = [ OutputSpec(OutputKind.USER_OUTPUT, TensorArgument(name=result.name), None) @@ -105,8 +108,11 @@ def rescale(source, dtype, scale, zp, out_zp=0): @pytest.mark.parametrize("adapted", [False, True]) -def test_fuses_blend_and_writes_only_final_rgb(adapted): - program, warps, sigmoid, _, _, result = _blend_program() +@pytest.mark.parametrize("output_unsigned", [False, True]) +def test_fuses_blend_and_writes_only_final_rgb(adapted, output_unsigned): + program, warps, sigmoid, _, _, result = _blend_program( + output_unsigned=output_unsigned + ) expected = match_rife_blend(program, warps, sigmoid) with patch.object(fuse, "build_final_warps_payload", return_value={}) as build: outcome = ( @@ -123,6 +129,8 @@ def test_fuses_blend_and_writes_only_final_rgb(adapted): assert len(custom.meta["val"]) == 1 assert tuple(custom.meta["val"][0].shape) == (1, 3, 4, 6) assert build.call_args.kwargs["blend"].rescales == expected.rescales + assert (expected.output_rescale is not None) == output_unsigned + assert not any(n.target == TOSA.RESCALE.default for n in result.users) assert not any( n.target in ( @@ -207,8 +215,9 @@ def apply(x, n): assert torch.equal(actual, expected.to(torch.int64)) -def test_blend_payload_and_fake_output_contract(): - program, warps, sigmoid, _, _, _ = _blend_program() +@pytest.mark.parametrize("output_unsigned", [False, True]) +def test_blend_payload_and_fake_output_contract(output_unsigned): + program, warps, sigmoid, _, _, _ = _blend_program(output_unsigned=output_unsigned) _, payloads, rescales, _, _, _, table, _, _ = fuse.FuseRifeFinalWarpsPass( program )._match(program.graph_module) @@ -222,9 +231,10 @@ def test_blend_payload_and_fake_output_contract(): source = compile_shader.call_args.args[0] assert "@" not in source assert "#define FUSE_BLEND 1" in source + assert f"#define OUTPUT_UNSIGNED {int(output_unsigned)}" in source assert "rescaleOutput(rescaleAdd0(left) + rescaleAdd1(right))" in source assert "tensorWriteARM(output0" in source - assert source.count("imulExtended") == 8 + assert source.count("imulExtended") == 8 + int(output_unsigned) assert "int(roundEven(float(value -" not in source assert payload["output_0_type"] == "Tensor" assert payload["output_0_vkformat"] == "VK_FORMAT_R8_SINT" @@ -234,3 +244,37 @@ def test_blend_payload_and_fake_output_contract(): [image, image], final_warps.BLEND_OPERATOR_NAME, None, None ) assert len(values) == 1 and tuple(values[0].shape) == (1, 3, 8, 12) + + +@pytest.mark.parametrize("scale", [0.5, 1.0038925936873089, 2.0]) +@pytest.mark.parametrize("zero_point", [0, 128, 255]) +def test_unsigned_output_rescale_matches_tosa_for_every_int8_code(scale, zero_point): + program, warps, sigmoid, _, _, result = _blend_program(output_unsigned=True) + result.args = (*result.args[:2], [scale], -127, zero_point) + blend = match_rife_blend(program, warps, sigmoid) + assert blend.output_rescale is result + codes = torch.arange(-128, 128, dtype=torch.int8) + with TosaLoweringContext(TosaSpecification.create_from_string("TOSA-1.0+INT")): + expected = TOSA.RESCALE.default(codes, *result.args[1:], **result.kwargs) + unsigned_codes = _apply_rescale(codes, result) + stored_codes = torch.where( + unsigned_codes < 128, unsigned_codes, unsigned_codes - 256 + ) + assert torch.equal(stored_codes.to(torch.int8), expected) + + +@pytest.mark.parametrize("invalid", ["input_unsigned", "zero_point", "shared_result"]) +def test_rejects_invalid_unsigned_boundary(invalid): + program, warps, sigmoid, _, _, result = _blend_program(output_unsigned=True) + if invalid == "input_unsigned": + result.kwargs = {"input_unsigned": True, "output_unsigned": True} + elif invalid == "zero_point": + result.args = (*result.args[:4], 256) + else: + with program.graph.inserting_before(result): + program.graph.call_function( + TOSA.RESCALE.default, + (result.args[0], torch.int8, [1.0], 0, 0), + ) + with pytest.raises(ValueError): + match_rife_blend(program, warps, sigmoid) diff --git a/examples/arm/QAT_example/rife_vgf/shaders/final_warps.glsl b/examples/arm/QAT_example/rife_vgf/shaders/final_warps.glsl index e722f2e12b3..e454afc996d 100644 --- a/examples/arm/QAT_example/rife_vgf/shaders/final_warps.glsl +++ b/examples/arm/QAT_example/rife_vgf/shaders/final_warps.glsl @@ -12,6 +12,7 @@ layout(set=0, binding=1) uniform sampler2D image1; layout(set=0, binding=2) readonly buffer PreviousFlow { uint previousFlow[]; }; layout(set=0, binding=3) uniform tensorARM tailPhase; #define FUSE_BLEND @FUSE_BLEND@ +#define OUTPUT_UNSIGNED @OUTPUT_UNSIGNED@ #if FUSE_BLEND layout(set=0, binding=4) uniform tensorARM output0; #else @@ -71,6 +72,11 @@ void main() { int left = clamp(rescaleMul0((image0Codes[c] - IMAGE0_ZERO_POINT) * sigmoid), -128, 127); int right = clamp(rescaleMul1((image1Codes[c] - IMAGE1_ZERO_POINT) * complement), -128, 127); int code = clamp(rescaleOutput(rescaleAdd0(left) + rescaleAdd1(right)), -128, 127); +#if OUTPUT_UNSIGNED + int unsignedCode = clamp(rescaleToUint8(code), 0, 255); + // Preserve the UINT8 bits in the TOSA tensor's INT8 storage. + code = unsignedCode < 128 ? unsignedCode : unsignedCode - 256; +#endif uint outputCoords[4] = uint[](0u, c, uint(p.y), uint(p.x)); tensorWriteARM(output0, outputCoords, int8_t(code)); } diff --git a/examples/arm/QAT_example/rife_vgf/shaders/final_warps.py b/examples/arm/QAT_example/rife_vgf/shaders/final_warps.py index 08ceb6e6531..a5ac384b01b 100644 --- a/examples/arm/QAT_example/rife_vgf/shaders/final_warps.py +++ b/examples/arm/QAT_example/rife_vgf/shaders/final_warps.py @@ -57,6 +57,7 @@ def build_final_warps_payload(payloads, rescales, sigmoid_table, *, blend=None): ) ] zero_points = "" + output_unsigned = blend is not None and blend.output_rescale is not None if blend is not None: functions.extend( _rescale_source(name, node) @@ -79,7 +80,10 @@ def build_final_warps_payload(payloads, rescales, sigmoid_table, *, blend=None): ) for i, value in enumerate(values) ) + if blend.output_rescale is not None: + functions.append(_rescale_source("rescaleToUint8", blend.output_rescale)) source = source.replace("@FUSE_BLEND@", str(int(blend is not None))) + source = source.replace("@OUTPUT_UNSIGNED@", str(int(output_unsigned))) source = source.replace("@BLEND_ZERO_POINTS@", zero_points) source = source.replace( "@COMPLEMENT_TABLE@", From decddc66c100442c700b99fd99998f7949183c73 Mon Sep 17 00:00:00 2001 From: Oscar Andersson <87121123+oscarandersson8218@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:16:01 +0100 Subject: [PATCH 092/128] Arm backend: Support dynamic transpose conv (#24648) Allow ungrouped transposed convolution with symbolic spatial dimensions when static parameters determine its output shape. This also indirectly adds partial support for dynamic PixelShuffle in cases when these can be rewritten as transposed convs. A PixelShuffle replacement test and quantized VGF export without shape extension test are also added. cc @digantdesai @freddan80 @per @zingo @mansnils @Sebastian-Larsson @robell @rascani Signed-off-by: Oscar Andersson --- .../tosa_supported_operators.py | 12 +- .../tosa_dialect/test_tosa_shape_support.py | 32 ++++++ backends/arm/test/ops/test_pixel_shuffling.py | 104 ++++++++++++++++++ 3 files changed, 143 insertions(+), 5 deletions(-) diff --git a/backends/arm/operator_support/tosa_supported_operators.py b/backends/arm/operator_support/tosa_supported_operators.py index 1c04406ee7f..ee4bf6dc24a 100644 --- a/backends/arm/operator_support/tosa_supported_operators.py +++ b/backends/arm/operator_support/tosa_supported_operators.py @@ -911,11 +911,13 @@ def _symbolic_spatial_op_requires_shape_extension(node: fx.Node) -> bool: return True if node.target == exir_ops.edge.aten.convolution.default: - return ( - bool(node.args[6]) - or bool(get_slices_convolution(node)) - or has_dynamic_conv_padding(node) - ) + if node.args[6]: + groups = node.args[8] + return bool(groups != 1) + return bool(get_slices_convolution(node)) or has_dynamic_conv_padding(node) + if node.target == torch.ops.aten.conv_transpose2d.input: + groups = node.args[6] + return bool(groups != 1) if node.target in ( exir_ops.edge.aten.avg_pool2d.default, exir_ops.edge.aten.max_pool2d.default, diff --git a/backends/arm/test/misc/tosa_dialect/test_tosa_shape_support.py b/backends/arm/test/misc/tosa_dialect/test_tosa_shape_support.py index b3ddd2e3658..417f86444ce 100644 --- a/backends/arm/test/misc/tosa_dialect/test_tosa_shape_support.py +++ b/backends/arm/test/misc/tosa_dialect/test_tosa_shape_support.py @@ -50,6 +50,15 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: return self.conv(x) +class TransposeConv2d(torch.nn.Module): + def __init__(self, groups: int = 1) -> None: + super().__init__() + self.conv = torch.nn.ConvTranspose2d(4, 4, 2, stride=2, groups=groups) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + return self.conv(x) + + class AvgPool2d(torch.nn.Module): def __init__( self, kernel_size: int = 2, stride: int | None = None, padding: int = 0 @@ -262,6 +271,29 @@ def test_without_shape_extension_accepts_symbolic_spatial_conv2d_without_input_a assert node.meta.get("delegation_tag") in partition_result.partition_tags +@pytest.mark.parametrize("groups,expected", [(1, True), (2, False)]) +def test_without_shape_extension_symbolic_spatial_transpose_conv2d(groups, expected): + exported_program = _exported_program( + TransposeConv2d(groups), + (torch.randn(2, 4, 8, 10),), + dynamic_shapes=( + { + 0: Dim("batch", min=1, max=4), + 2: Dim("height", min=2, max=16), + 3: Dim("width", min=2, max=16), + }, + ), + ) + partition_result = TOSAPartitioner(TosaCompileSpec("TOSA-1.0+FP")).partition( + exported_program + ) + node = _find_node(exported_program, exir_ops.edge.aten.convolution.default) + + assert ( + node.meta.get("delegation_tag") in partition_result.partition_tags + ) is expected + + def test_without_shape_extension_rejects_symbolic_spatial_conv2d_needing_input_adjustment(): inputs = (torch.randn(2, 3, 8, 8),) height = Dim("height", min=4, max=10) diff --git a/backends/arm/test/ops/test_pixel_shuffling.py b/backends/arm/test/ops/test_pixel_shuffling.py index 4980e24bab3..aa14ad63444 100644 --- a/backends/arm/test/ops/test_pixel_shuffling.py +++ b/backends/arm/test/ops/test_pixel_shuffling.py @@ -3,8 +3,10 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. +from math import prod from typing import Tuple +import pytest import torch from executorch.backends.arm.constants import MAX_RANK from executorch.backends.arm.test import common @@ -16,6 +18,7 @@ VgfPipeline, ) from torch import nn +from torch.export import Dim aten_op_pixel_unshuffle = "torch.ops.aten.pixel_unshuffle.default" exir_op_pixel_unshuffle = ( @@ -96,6 +99,107 @@ def forward(self, inputs: torch.Tensor) -> torch.Tensor: return self.depth_to_space(inputs) +class PixelShuffleAsTransposeConv2d(nn.Module): + def __init__(self, channels: int = 3, upscale_factor: int = 2) -> None: + super().__init__() + channels_per_output = upscale_factor**2 + self.transpose_conv = nn.ConvTranspose2d( + channels * channels_per_output, + channels, + kernel_size=upscale_factor, + stride=upscale_factor, + bias=False, + ) + with torch.no_grad(): + self.transpose_conv.weight.zero_() + for channel in range(channels): + for row in range(upscale_factor): + for col in range(upscale_factor): + input_channel = ( + channel * channels_per_output + row * upscale_factor + col + ) + self.transpose_conv.weight[input_channel, channel, row, col] = 1 + self.transpose_conv.weight.requires_grad_(False) + + def forward(self, inputs: torch.Tensor) -> torch.Tensor: + return self.transpose_conv(inputs) + + +_dynamic_pixel_shuffle_shapes = ( + { + 0: Dim("batch", min=1, max=4), + 2: Dim("height", min=2, max=16), + 3: Dim("width", min=2, max=16), + }, +) + + +@common.parametrize( + "case", + { + "channels_last_8x10": (3, 2, (2, 12, 8, 10), True), + "other_dynamic_shape": (3, 2, (1, 12, 5, 7), False), + "larger_factor": (2, 3, (2, 18, 4, 6), False), + }, +) +def test_pixel_shuffle_as_transpose_conv2d_matches(case): + channels, upscale_factor, shape, channels_last = case + inputs = torch.arange(prod(shape), dtype=torch.float32).reshape(shape) + if channels_last: + inputs = inputs.to(memory_format=torch.channels_last) + rewritten = PixelShuffleAsTransposeConv2d(channels, upscale_factor) + + torch.testing.assert_close( + rewritten(inputs), nn.PixelShuffle(upscale_factor)(inputs), rtol=0, atol=0 + ) + + +@common.parametrize("run_on_ref_model", {"lowering": False, "numerics": True}) +def test_pixel_shuffle_as_transpose_conv2d_tosa_INT_dynamic_shapes(run_on_ref_model): + if run_on_ref_model and not TosaPipelineINT.is_tosa_ref_model_available(): + pytest.skip("TOSA reference model is required for numerical comparison") + + pipeline = TosaPipelineINT[input_t1]( + PixelShuffleAsTransposeConv2d(), + (torch.randn(2, 12, 8, 10),), + "torch.ops.aten.conv_transpose2d.input", + "executorch_exir_dialects_edge__ops_aten_convolution_default", + dynamic_shapes=_dynamic_pixel_shuffle_shapes, + tosa_version="1.0", + tosa_extensions=[], + per_channel_quantization=False, + run_on_tosa_ref_model=run_on_ref_model, + ) + pipeline.pop_stage("to_executorch") + pipeline.count_tosa_ops({"TRANSPOSE_CONV2D": 1}) + pipeline.run() + + +@common.SkipIfNoModelConverter +@common.parametrize( + "case", + { + "upscale_2_8x10": (3, 2, (2, 12, 8, 10)), + "other_dynamic_shape": (3, 2, (3, 12, 5, 7)), + "larger_factor": (2, 3, (2, 18, 4, 6)), + }, +) +def test_pixel_shuffle_as_transpose_conv2d_vgf_quant_dynamic_shapes(case): + channels, upscale_factor, sample_shape = case + pipeline = VgfPipeline[input_t1]( + PixelShuffleAsTransposeConv2d(channels, upscale_factor), + (torch.randn(sample_shape).to(memory_format=torch.channels_last),), + "torch.ops.aten.conv_transpose2d.input", + "executorch_exir_dialects_edge__ops_aten_convolution_default", + run_on_vulkan_runtime=False, + quantize=True, + per_channel_quantization=False, + dynamic_shapes=_dynamic_pixel_shuffle_shapes, + tosa_spec="TOSA-1.0+FP+INT+int4+int16", + ) + pipeline.run() + + @common.parametrize("test_data", PixelUnShuffle.test_data_generators) def test_pixel_unshuffle_tosa_FP(test_data: input_t1): inputs, expected_transposes = test_data() From 850b10223d6c136bab3a8692be1249cf69cd658a Mon Sep 17 00:00:00 2001 From: SaoirseARM <44364573+SaoirseARM@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:20:45 +0000 Subject: [PATCH 093/128] Arm backend: Handle integer true division in symbolic analysis (#24421) Add int_truediv support for bounded dynamic shape expressions so symbolic value analysis no longer blocks export. Return an unknown result when the divisor can be zero, and cover the division cases with focused tests. Signed-off-by: Saoirse Stewart --- backends/arm/_passes/symbolic_value_range.py | 17 ++++++ .../test/passes/test_symbolic_value_range.py | 54 ++++++++++++++++++- 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/backends/arm/_passes/symbolic_value_range.py b/backends/arm/_passes/symbolic_value_range.py index 36bff1ed47a..7675dcb94a4 100644 --- a/backends/arm/_passes/symbolic_value_range.py +++ b/backends/arm/_passes/symbolic_value_range.py @@ -11,6 +11,7 @@ from torch.utils._sympy.interp import sympy_interp _MAX_SET_SIZE = 256 +_MAX_EXACT_FLOAT_INT = 2**53 _ExactValues = Optional[frozenset[sympy.Basic]] @@ -159,6 +160,22 @@ def floordiv(lhs: _ExactValues, rhs: _ExactValues) -> _ExactValues: def python_floordiv(lhs: _ExactValues, rhs: _ExactValues) -> _ExactValues: return _ExactValueAnalysis.floordiv(lhs, rhs) + @staticmethod + def int_truediv(lhs: _ExactValues, rhs: _ExactValues) -> _ExactValues: + if ( + lhs is None + or rhs is None + or any( + b == 0 or abs(a // b) > _MAX_EXACT_FLOAT_INT for a in lhs for b in rhs + ) + ): + return None + return _combine_values( + lhs, + rhs, + lambda a, b: a // b if a % b == 0 else sympy.Float(int(a) / int(b)), + ) + @staticmethod def pow(lhs: _ExactValues, rhs: _ExactValues) -> _ExactValues: return _combine_values(lhs, rhs, lambda a, b: a**b) diff --git a/backends/arm/test/passes/test_symbolic_value_range.py b/backends/arm/test/passes/test_symbolic_value_range.py index 698be1a8bd2..54fc898f54f 100644 --- a/backends/arm/test/passes/test_symbolic_value_range.py +++ b/backends/arm/test/passes/test_symbolic_value_range.py @@ -9,7 +9,7 @@ evaluate_symbolic_expr_values, ) from torch.fx.experimental.symbolic_shapes import ShapeEnv -from torch.utils._sympy.functions import PythonMod +from torch.utils._sympy.functions import IntTrueDiv, PythonMod def _make_shape_env( @@ -103,3 +103,55 @@ class PythonFloorDiv(sympy.Function): assert evaluate_symbolic_expr_values( PythonFloorDiv(symint.node.expr, 2), shape_env ) == {1, 2, 3} + + +def test_evaluate_symbolic_expr_values_handles_int_truediv() -> None: + shape_env, symint = _make_shape_env(hint=4, compiler_min=4, compiler_max=4) + + assert evaluate_symbolic_expr_values( + IntTrueDiv(symint.node.expr, 2), shape_env + ) == {2} + + +def test_evaluate_symbolic_expr_values_handles_fractional_int_truediv() -> None: + shape_env, symint = _make_shape_env(hint=3, compiler_min=2, compiler_max=6) + + assert ( + evaluate_symbolic_expr_values(IntTrueDiv(symint.node.expr, 2), shape_env) + is None + ) + assert evaluate_symbolic_expr_values( + sympy.floor(IntTrueDiv(symint.node.expr, 2)), shape_env + ) == {1, 2, 3} + + +def test_evaluate_symbolic_expr_values_handles_exact_float_limit() -> None: + value = 2**54 + shape_env, symint = _make_shape_env( + hint=value, compiler_min=value, compiler_max=value + ) + + assert evaluate_symbolic_expr_values( + IntTrueDiv(symint.node.expr, 2), shape_env + ) == {2**53} + + +def test_evaluate_symbolic_expr_values_bails_out_on_inexact_float_quotient() -> None: + value = 2**54 + 2 + shape_env, symint = _make_shape_env( + hint=value, compiler_min=value, compiler_max=value + ) + + assert ( + evaluate_symbolic_expr_values(IntTrueDiv(symint.node.expr, 2), shape_env) + is None + ) + + +def test_evaluate_symbolic_expr_values_handles_zero_divisor() -> None: + shape_env, symint = _make_shape_env(hint=1, compiler_min=0, compiler_max=2) + + assert ( + evaluate_symbolic_expr_values(IntTrueDiv(4, symint.node.expr), shape_env) + is None + ) From 724cef088a1c0956346b122a3f3fcb7751749a05 Mon Sep 17 00:00:00 2001 From: Elena Zhelezina Date: Thu, 5 Nov 2026 15:22:43 +0000 Subject: [PATCH 094/128] Arm backend: Fixed zero copy IO after testing on phone. (#24661) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit

PR: Arm VGF — Enable Zero-Copy I/O Using VGF-Owned Mapped Memory

Summary

This PR introduces VGF-owned mapped boundary I/O for ExecuTorch, allowing input and output tensors to directly access persistently mapped Vulkan memory without redundant CPU-side memcpy operations.

Unlike VK_EXT_external_memory_host import, which requires compatible host-import and GPU tensor memory types, this approach uses memory allocated by VGF and exposes its mapping to ExecuTorch.

The existing copy path remains available for comparison and fallback.

Performance — Xiaomi Android Device (Arm Mali GPU)

| Model | Boundary data eliminated | Copy p50 (ms) | Mapped p50 (ms) | Latency reduction | Speedup | |---|---:|---:|---:|---:|---:| | MobileNetV2 FP32 | 0.61 MB | 9.456 | 9.474 | -0.19% | 1.00x | | NSS FP32 | 38.12 MB | 120.725 | 61.663 | 48.92% | 1.96x | | RGB CNN 256x256 FP32 | 1.57 MB | 3.614 | 1.102 | 69.51% | 3.28x | | RGB CNN 512x512 FP32 | 6.29 MB | 13.491 | 2.622 | **80.56%** | **5.14x** | | RGB CNN 1920x1080 FP32 | 49.77 MB | 75.745 | 17.212 | 77.28% | 4.40x | **Benchmark configuration:** - Device: Xiaomi (Arm Mali GPU NX) - Precision: FP32 - Runs: 10 per execution mode - Warmup iterations: 20 per run - Measured iterations: 200 per run - Latency: Mean of run-level p50 measurements - Validation: Byte-exact output parity and strict per-iteration transfer accounting

Validation

  • Benchmarked on Xiaomi 2608BPX34C with an Arm Mali GPU.

  • 10 repeated runs per mode, with 20 warmups and 200 measured iterations per run.

  • Reported latency is the mean of run-level p50 measurements.

  • Copy and mapped outputs pass byte-exact parity checks.

  • Strict per-iteration boundary-transfer accounting passes.

Conclusion

VGF-owned mapped I/O provides substantial acceleration for memory-sensitive inference workloads, achieving up to 5.14× speedup. Benefits depend on the model's boundary-transfer overhead relative to GPU computation, as demonstrated by the negligible improvement for MobileNetV2.

cc @digantdesai @freddan80 @per @zingo @oscarandersson8218 @mansnils @Sebastian-Larsson @robell @rascani Signed-off-by: Elena Zhelezina --- backends/arm/CMakeLists.txt | 4 +- backends/arm/runtime/VGFBackend.cpp | 184 ++++++++++-- backends/arm/runtime/VGFBoundaryIO.h | 76 +++++ backends/arm/runtime/VGFSetup.cpp | 347 ++++++++++++++++++++++- backends/arm/runtime/VGFSetup.h | 25 ++ backends/arm/runtime/targets.bzl | 1 + backends/arm/test/vgf_zero_copy_test.cpp | 19 ++ 7 files changed, 617 insertions(+), 39 deletions(-) create mode 100644 backends/arm/runtime/VGFBoundaryIO.h diff --git a/backends/arm/CMakeLists.txt b/backends/arm/CMakeLists.txt index 15371937dd4..f6d2dfdbb0b 100644 --- a/backends/arm/CMakeLists.txt +++ b/backends/arm/CMakeLists.txt @@ -290,8 +290,8 @@ if(EXECUTORCH_BUILD_VGF) list(TRANSFORM _vgf_backend_sources PREPEND "${EXECUTORCH_ROOT}/") add_library(vgf_backend ${_vgf_backend_sources}) - option(EXECUTORCH_VGF_ZERO_COPY_IO - "Enable VGF model-boundary host zero-copy I/O" OFF + option(EXECUTORCH_VGF_ZERO_COPY_IO "Enable VGF model-boundary zero-copy I/O" + OFF ) if(EXECUTORCH_VGF_ZERO_COPY_IO) target_compile_definitions(vgf_backend PUBLIC EXECUTORCH_VGF_ZERO_COPY_IO=1) diff --git a/backends/arm/runtime/VGFBackend.cpp b/backends/arm/runtime/VGFBackend.cpp index e4bdd86aa4c..f6535fc0c70 100644 --- a/backends/arm/runtime/VGFBackend.cpp +++ b/backends/arm/runtime/VGFBackend.cpp @@ -21,6 +21,7 @@ #include #include #include +#include using namespace std; @@ -65,6 +66,7 @@ using executorch::runtime::EventTracerEntry; #include #include +#include #include #include @@ -457,6 +459,50 @@ class VGFBackend final : public ::executorch::runtime::BackendInterface { public: VGFBackend() = default; + Error get_mapped_boundary_buffer( + const executorch::runtime::MemoryAllocator* method_allocator, + bool input, + size_t argument_index, + VgfMappedBoundaryBuffer* buffer) const noexcept { + if (method_allocator == nullptr || buffer == nullptr) { + return Error::InvalidArgument; + } + *buffer = {}; + if (!vgf_zero_copy_io_build_enabled() || + !vgf_zero_copy_io_runtime_enabled(std::getenv(kVgfZeroCopyIoEnv))) { + return Error::NotSupported; + } + + // The same mutex covers registration, execute() (including terminal + // VgfRepr invalidation), and destroy(). Never dereference an unregistered + // representation or an unsynchronized IO allocation. + const std::lock_guard lock(mutex_); + const MappedBoundaryEntry* match = nullptr; + for (const auto& entry : mapped_boundary_reprs_) { + if (entry.method_allocator != method_allocator) { + continue; + } + if (match != nullptr) { + // Two delegates sharing an allocator cannot be differentiated by this + // key; in particular a Method containing two VGF partitions is unsafe. + return Error::NotSupported; + } + match = &entry; + } + if (match == nullptr) { + return Error::NotFound; + } + if (!match->repr->is_execution_valid()) { + return Error::InvalidState; + } + const Error status = match->repr->get_owned_mapped_boundary_buffer( + input, argument_index, buffer); + if (status == Error::Ok) { + buffer->generation = match->generation; + } + return status; + } + // Lazy Vulkan init. All callers hold mutex_; no Vulkan work is done // from the process-global backend's constructor or destructor. void ensure_initialized() { @@ -659,6 +705,12 @@ class VGFBackend final : public ::executorch::runtime::BackendInterface { event_tracer_end_profiling_delegate(event_tracer, init_total_event); #endif + // init() already holds mutex_; identify the owning Method by its + // allocator (the client must not share it across live Methods). + self->mapped_boundary_reprs_.push_back(MappedBoundaryEntry{ + context.get_runtime_allocator(), + repr, + self->next_mapped_boundary_generation_++}); ++self->live_delegates_; return repr; } @@ -728,23 +780,58 @@ class VGFBackend final : public ::executorch::runtime::BackendInterface { (void)context; #endif - const bool zero_copy_enabled = vgf_zero_copy_io_should_attempt( - vgf_zero_copy_io_build_enabled(), - std::getenv(kVgfZeroCopyIoEnv), - repr->host_memory_import_enabled()); + const bool zero_copy_requested = vgf_zero_copy_io_build_enabled() && + vgf_zero_copy_io_runtime_enabled(std::getenv(kVgfZeroCopyIoEnv)); + const bool host_import_enabled = + zero_copy_requested && repr->host_memory_import_enabled(); + + enum class BoundaryIoMode : uint8_t { + kCopy, + kImportedHost, + kOwnedMapped, + }; VgfZeroCopyExecutionCleanup zero_copy_cleanup(repr); - std::vector zero_copy_outputs(output_count, false); + std::vector output_modes( + output_count, BoundaryIoMode::kCopy); // Prepare one model-boundary tensor. Normal eligibility/import rejection // returns imported=false and leaves/selects the unconditional owned path. // Only an internal inability to restore a previously active owned binding // is treated as a runtime error. - auto prepare_zero_copy = - [&](size_t io_idx, Tensor* tensor, bool input, bool* imported) -> bool { - *imported = false; + auto prepare_zero_copy = [&](size_t io_idx, + Tensor* tensor, + bool input, + BoundaryIoMode* mode) -> bool { + *mode = BoundaryIoMode::kCopy; + const size_t logical_nbytes = tensor->nbytes(); - if (!zero_copy_enabled) { + // Preferred path: the Method tensor is already backed by the VGF-owned + // persistent mapping. No external-memory import, descriptor replacement, + // command-buffer rerecord, or boundary memcpy is needed. + if (repr->is_owned_mapped_io_pointer( + io_idx, tensor->mutable_data_ptr(), logical_nbytes)) { + if (repr->is_import_active(io_idx) && + !repr->activate_owned_binding(io_idx)) { + ET_LOG( + Error, + "Failed to restore owned VGF boundary binding for IO %zu", + io_idx); + return false; + } + if (input && !repr->flush_io_host_writes(io_idx)) { + return false; + } + *mode = BoundaryIoMode::kOwnedMapped; + if (input) { + VGF_STATS_ZERO_COPY_IN(logical_nbytes); + } else { + VGF_STATS_ZERO_COPY_OUT(logical_nbytes); + } + return true; + } + + if (!host_import_enabled) { if (repr->is_import_active(io_idx) && !repr->activate_owned_binding(io_idx)) { ET_LOG( @@ -756,12 +843,11 @@ class VGFBackend final : public ::executorch::runtime::BackendInterface { return true; } - const size_t logical_nbytes = tensor->nbytes(); const auto import_result = repr->try_import_zero_copy_candidate( io_idx, tensor->mutable_data_ptr(), logical_nbytes); if (import_result.accepted && repr->is_import_active(io_idx)) { - *imported = true; + *mode = BoundaryIoMode::kImportedHost; if (input) { VGF_STATS_ZERO_COPY_IN(logical_nbytes); } else { @@ -790,8 +876,6 @@ class VGFBackend final : public ::executorch::runtime::BackendInterface { vgf_zero_copy_import_rejection_reason_string(import_result.reason), static_cast(import_result.vk_result)); } else { - // Accepted-but-not-selected should be impossible, but copy fallback is - // still safer than dispatching with an unexpected descriptor state. ET_LOG( Debug, "VGF zero-copy import for IO %zu was accepted but no imported " @@ -849,19 +933,20 @@ class VGFBackend final : public ::executorch::runtime::BackendInterface { return Error::InvalidArgument; } - bool imported = false; + BoundaryIoMode mode = BoundaryIoMode::kCopy; if (!prepare_zero_copy( - static_cast(io_idx), tensor, /*input=*/true, &imported)) { + static_cast(io_idx), tensor, /*input=*/true, &mode)) { #ifdef ET_EVENT_TRACER_ENABLED event_tracer_end_profiling_delegate(event_tracer, copy_inputs_event); event_tracer_end_profiling_delegate(event_tracer, vgf_execute_event); #endif return Error::Internal; } - if (imported) { + if (mode != BoundaryIoMode::kCopy) { ET_LOG( Debug, - "Zero-copy input IO[%d] <- args[%zu]", + "%s input IO[%d] <- args[%zu]", + mode == BoundaryIoMode::kOwnedMapped ? "Owned-mapped" : "Imported", io_idx, input_arg_idx); continue; @@ -878,6 +963,10 @@ class VGFBackend final : public ::executorch::runtime::BackendInterface { return Error::Internal; } VGF_STATS_MEMCPY_IN(data, tensor->mutable_data_ptr(), io_size); + if (!repr->flush_io_host_writes(static_cast(io_idx))) { + ET_LOG(Error, "Failed to flush VGF input IO %d", io_idx); + return Error::Internal; + } repr->unmap_io(io); } @@ -935,12 +1024,12 @@ class VGFBackend final : public ::executorch::runtime::BackendInterface { return Error::InvalidArgument; } - bool imported = false; + BoundaryIoMode mode = BoundaryIoMode::kCopy; if (!prepare_zero_copy( static_cast(io_idx), tensor, /*input=*/false, - &imported)) { + &mode)) { #ifdef ET_EVENT_TRACER_ENABLED event_tracer_end_profiling_delegate( event_tracer, prepare_outputs_event); @@ -949,11 +1038,15 @@ class VGFBackend final : public ::executorch::runtime::BackendInterface { return Error::Internal; } - zero_copy_outputs[output_rel_idx] = imported; + output_modes[output_rel_idx] = mode; + const char* mode_name = mode == BoundaryIoMode::kOwnedMapped + ? "Owned-mapped" + : (mode == BoundaryIoMode::kImportedHost ? "Imported" + : "Copy fallback"); ET_LOG( Debug, "%s output IO[%d] -> args[%zu]", - imported ? "Zero-copy" : "Owned fallback", + mode_name, io_idx, output_arg_idx); } @@ -1034,7 +1127,15 @@ class VGFBackend final : public ::executorch::runtime::BackendInterface { ++output_rel_idx) { const size_t output_arg_idx = input_count + output_rel_idx; const int io_idx = repr->model_output_io_index[output_rel_idx]; - if (zero_copy_outputs[output_rel_idx]) { + const BoundaryIoMode mode = output_modes[output_rel_idx]; + if (mode == BoundaryIoMode::kImportedHost) { + continue; + } + if (!repr->invalidate_io_host_reads(static_cast(io_idx))) { + ET_LOG(Error, "Failed to invalidate VGF output IO %d", io_idx); + return Error::Internal; + } + if (mode == BoundaryIoMode::kOwnedMapped) { continue; } @@ -1086,10 +1187,19 @@ class VGFBackend final : public ::executorch::runtime::BackendInterface { auto* self = const_cast(this); const std::lock_guard lock(mutex_); ET_CHECK_MSG(live_delegates_ > 0, "Unbalanced VGF delegate destruction"); + VgfRepr* repr = static_cast(handle); + // destroy() already holds mutex_; unregister before freeing the IO. + self->mapped_boundary_reprs_.erase( + std::remove_if( + self->mapped_boundary_reprs_.begin(), + self->mapped_boundary_reprs_.end(), + [repr](const MappedBoundaryEntry& entry) { + return entry.repr == repr; + }), + self->mapped_boundary_reprs_.end()); // A failed execution may have submitted work without completing its // fence wait. Drain it before freeing any per-delegate resources. self->wait_for_device_idle(); - VgfRepr* repr = static_cast(handle); repr->~VgfRepr(); --self->live_delegates_; self->release_basics_if_unused(); @@ -1142,7 +1252,16 @@ class VGFBackend final : public ::executorch::runtime::BackendInterface { // Do not call volkFinalize(): the Vulkan backend shares the loader. } + struct MappedBoundaryEntry { + const executorch::runtime::MemoryAllocator* method_allocator = nullptr; + VgfRepr* repr = nullptr; + uint64_t generation = 0; + }; + mutable std::mutex mutex_; + // The registry and sequence counter are protected by mutex_. + std::vector mapped_boundary_reprs_; + uint64_t next_mapped_boundary_generation_ = 1; size_t live_delegates_ = 0; VkInstance vk_instance = VK_NULL_HANDLE; VkPhysicalDevice vk_physical_device = VK_NULL_HANDLE; @@ -1156,6 +1275,25 @@ class VGFBackend final : public ::executorch::runtime::BackendInterface { bool is_initialized_ = false; }; +// cppcheck-suppress unusedFunction +executorch::runtime::Error get_vgf_mapped_boundary_buffer( + const executorch::runtime::MemoryAllocator* method_allocator, + bool input, + size_t argument_index, + VgfMappedBoundaryBuffer* buffer) noexcept { + if (method_allocator == nullptr || buffer == nullptr) { + return Error::InvalidArgument; + } + *buffer = {}; + const auto* backend = static_cast( + executorch::runtime::get_backend_class("VgfBackend")); + if (backend == nullptr) { + return Error::NotFound; + } + return backend->get_mapped_boundary_buffer( + method_allocator, input, argument_index, buffer); +} + namespace { auto cls = VGFBackend(); Backend backend{"VgfBackend", &cls}; diff --git a/backends/arm/runtime/VGFBoundaryIO.h b/backends/arm/runtime/VGFBoundaryIO.h new file mode 100644 index 00000000000..22f73ba668f --- /dev/null +++ b/backends/arm/runtime/VGFBoundaryIO.h @@ -0,0 +1,76 @@ +/* + * Copyright 2026 Arm Limited and/or its affiliates. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ +#pragma once + +#include +#include + +#include + +namespace executorch { +namespace runtime { +class MemoryAllocator; +} // namespace runtime +namespace backends { +namespace vgf { + +/** + * CPU mapping of one VGF-owned model-boundary allocation. + * + * Returned mappings remain valid until Method destruction or a terminal + * execution error, whichever occurs first. After an execution error, discard + * all borrowed mappings and bound tensor pointers without accessing data. + * Never free/unmap the VGF-owned pointer. + * + * Host accesses must be serialized with Method::execute() and Method + * destruction. The backend mutex protects this lookup only, not subsequent + * use of the returned pointer. The backend handles non-coherent memory cache + * maintenance during execute(). + * + * This API assumes each simultaneously loaded Method has its OWN method + * MemoryAllocator. Pass exactly the allocator supplied to Program::load_method + * by the Method's MemoryManager::method_allocator(). The backend receives the + * same pointer through BackendInitContext::get_runtime_allocator(). If two + * delegates share that pointer, the query rejects the ambiguous key. + * + * Direct binding further requires a 1:1 Method-to-VGF-delegate boundary + * mapping, e.g. full delegation with graph IO memory planning disabled. + */ +struct VgfMappedBoundaryBuffer { + void* data = nullptr; + size_t logical_nbytes = 0; + size_t capacity_nbytes = 0; + bool host_coherent = false; + uint64_t generation = 0; +}; + +/** + * Return the mapped boundary for a Method with a unique method allocator. + * + * Rejects duplicate allocator registrations (including multiple VGF + * partitions), missing/invalid delegates, and ineligible IO allocations. + * The build must enable EXECUTORCH_VGF_ZERO_COPY_IO and runtime zero-copy must + * not be disabled via ET_VGF_ZERO_COPY=0. + */ +executorch::runtime::Error get_vgf_mapped_boundary_buffer( + const executorch::runtime::MemoryAllocator* method_allocator, + bool input, + size_t argument_index, + VgfMappedBoundaryBuffer* buffer) noexcept; + +/** Exact-match predicate used by the backend fast path and unit tests. */ +inline bool vgf_owned_mapped_buffer_matches( + const VgfMappedBoundaryBuffer& buffer, + const void* pointer, + size_t nbytes) noexcept { + return buffer.data != nullptr && pointer == buffer.data && + nbytes == buffer.logical_nbytes; +} + +} // namespace vgf +} // namespace backends +} // namespace executorch diff --git a/backends/arm/runtime/VGFSetup.cpp b/backends/arm/runtime/VGFSetup.cpp index a75998337f6..414753498e8 100644 --- a/backends/arm/runtime/VGFSetup.cpp +++ b/backends/arm/runtime/VGFSetup.cpp @@ -820,6 +820,13 @@ bool VgfRepr::map_persistent_io_memory() { unmap_persistent_io_memory(); for (auto& io : IOs) { + // Cache maintenance cannot infer coherency from missing metadata. + if (!io.memory_properties_known || + (io.memory_property_flags & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) == 0) { + ET_LOG(Error, "VGF IO has unknown or non-host-visible memory properties"); + unmap_persistent_io_memory(); + return false; + } if (io.memory == VK_NULL_HANDLE) { ET_LOG(Error, "Cannot persistently map null Vulkan IO memory"); unmap_persistent_io_memory(); @@ -831,7 +838,8 @@ bool VgfRepr::map_persistent_io_memory() { // IO resources may alias the same VkDeviceMemory. Vulkan memory must not be // mapped more than once at the same time, so map each unique memory once // and share the returned pointer across aliased IO entries. - // Make sure that memory is HOST_VISIBLE and HOST_COHERENT. + // IO memory is HOST_VISIBLE. Boundary tensors may intentionally use + // non-coherent memory; execute() performs explicit cache maintenance. bool found_existing_mapping = false; auto mapped_memory_it = std::find_if( persistent_mapped_memories.begin(), @@ -883,6 +891,157 @@ void VgfRepr::unmap_persistent_io_memory() { } } +executorch::runtime::Error VgfRepr::get_owned_mapped_boundary_buffer( + bool input, + size_t argument_index, + VgfMappedBoundaryBuffer* buffer) const { + if (buffer == nullptr) { + return executorch::runtime::Error::InvalidArgument; + } + *buffer = {}; + + const auto& mapping = input ? model_input_io_index : model_output_io_index; + const size_t argument_count = input ? model_input_count : model_output_count; + if (argument_index >= argument_count || argument_index >= mapping.size()) { + return executorch::runtime::Error::InvalidArgument; + } + + const int mapped_io_index = mapping[argument_index]; + if (mapped_io_index < 0) { + return executorch::runtime::Error::NotFound; + } + const size_t io_index = static_cast(mapped_io_index); + if (io_index >= IOs.size()) { + return executorch::runtime::Error::Internal; + } + + const IO& io = IOs[io_index]; + if (!io.owned_mapped_boundary_eligible || io.persistent_memory == nullptr || + io.memory == VK_NULL_HANDLE || !io.memory_properties_known) { + return executorch::runtime::Error::NotSupported; + } + if (io.memory_allocation_size > std::numeric_limits::max()) { + return executorch::runtime::Error::NotSupported; + } + + *buffer = VgfMappedBoundaryBuffer{ + .data = io.persistent_memory, + .logical_nbytes = io.allocation_size, + .capacity_nbytes = static_cast(io.memory_allocation_size), + .host_coherent = (io.memory_property_flags & + VK_MEMORY_PROPERTY_HOST_COHERENT_BIT) != 0, + .generation = 0, + }; + return executorch::runtime::Error::Ok; +} + +bool VgfRepr::is_owned_mapped_io_pointer( + size_t io_index, + const void* pointer, + size_t logical_nbytes) const { + if (io_index >= IOs.size()) { + return false; + } + const IO& io = IOs[io_index]; + if (!io.owned_mapped_boundary_eligible || io.persistent_memory == nullptr) { + return false; + } + const VgfMappedBoundaryBuffer buffer{ + .data = io.persistent_memory, + .logical_nbytes = io.allocation_size, + .capacity_nbytes = io.allocation_size, + .host_coherent = (io.memory_property_flags & + VK_MEMORY_PROPERTY_HOST_COHERENT_BIT) != 0, + .generation = 0, + }; + return vgf_owned_mapped_buffer_matches(buffer, pointer, logical_nbytes); +} + +bool VgfRepr::flush_io_host_writes(size_t io_index) { + if (io_index >= IOs.size()) { + ET_LOG(Error, "Invalid VGF IO index %zu for host flush", io_index); + return false; + } + const IO& io = IOs[io_index]; + if (!io.memory_properties_known) { + ET_LOG( + Error, + "VGF IO %zu has unknown memory properties; cannot flush", + io_index); + return false; + } + if ((io.memory_property_flags & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT) != 0) { + return true; + } + if (io.memory == VK_NULL_HANDLE || + (io.memory_property_flags & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) == 0) { + ET_LOG(Error, "VGF IO %zu is not flushable host-visible memory", io_index); + return false; + } + + // Each direct boundary allocation is isolated and mapped from offset zero. + // VK_WHOLE_SIZE avoids sub-range/nonCoherentAtomSize alignment hazards. + const VkMappedMemoryRange range{ + .sType = VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE, + .pNext = nullptr, + .memory = io.memory, + .offset = 0, + .size = VK_WHOLE_SIZE, + }; + const VkResult result = vkFlushMappedMemoryRanges(vk_device, 1, &range); + if (result != VK_SUCCESS) { + ET_LOG( + Error, + "Failed to flush non-coherent VGF IO %zu, error %d", + io_index, + result); + return false; + } + return true; +} + +bool VgfRepr::invalidate_io_host_reads(size_t io_index) { + if (io_index >= IOs.size()) { + ET_LOG(Error, "Invalid VGF IO index %zu for host invalidate", io_index); + return false; + } + const IO& io = IOs[io_index]; + if (!io.memory_properties_known) { + ET_LOG( + Error, + "VGF IO %zu has unknown memory properties; cannot invalidate", + io_index); + return false; + } + if ((io.memory_property_flags & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT) != 0) { + return true; + } + if (io.memory == VK_NULL_HANDLE || + (io.memory_property_flags & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) == 0) { + ET_LOG( + Error, "VGF IO %zu is not invalidatable host-visible memory", io_index); + return false; + } + + const VkMappedMemoryRange range{ + .sType = VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE, + .pNext = nullptr, + .memory = io.memory, + .offset = 0, + .size = VK_WHOLE_SIZE, + }; + const VkResult result = vkInvalidateMappedMemoryRanges(vk_device, 1, &range); + if (result != VK_SUCCESS) { + ET_LOG( + Error, + "Failed to invalidate non-coherent VGF IO %zu, error %d", + io_index, + result); + return false; + } + return true; +} + VkResult allocate_memory( VkPhysicalDevice physical, VkDevice device, @@ -913,6 +1072,74 @@ VkResult allocate_memory( return result; } +static VkMemoryPropertyFlags memory_type_property_flags( + VkPhysicalDevice physical, + uint32_t memory_type_index) { + VkPhysicalDeviceMemoryProperties properties{}; + vkGetPhysicalDeviceMemoryProperties(physical, &properties); + if (memory_type_index >= properties.memoryTypeCount) { + return 0; + } + return properties.memoryTypes[memory_type_index].propertyFlags; +} + +// Boundary tensors are already VGF-owned, so they do not need an external-host +// compatible memory type. Prefer the historical coherent/device-local choice, +// but when zero-copy is built allow any tensor-compatible HOST_VISIBLE type and +// perform explicit flush/invalidate for non-coherent memory. +static VkResult allocate_boundary_host_visible_memory( + VkPhysicalDevice physical, + VkDevice device, + const VkMemoryRequirements2& memory_requirements, + VkDeviceMemory* memory, + uint32_t* memory_type_index_out, + VkMemoryPropertyFlags* memory_property_flags_out) { + constexpr VkMemoryPropertyFlags candidates[] = { + VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT | + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | + VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, + VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT | + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | + VK_MEMORY_PROPERTY_HOST_CACHED_BIT, + VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT | VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, + }; + + for (const VkMemoryPropertyFlags aims : candidates) { + uint32_t memory_index = 0; + if (!find_memory_index( + physical, memory_requirements, aims, &memory_index)) { + continue; + } + + const VkMemoryAllocateInfo allocate_info{ + .sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, + .pNext = nullptr, + .allocationSize = memory_requirements.memoryRequirements.size, + .memoryTypeIndex = memory_index, + }; + const VkResult result = + vkAllocateMemory(device, &allocate_info, nullptr, memory); + if (result != VK_SUCCESS) { + return result; + } + + if (memory_type_index_out != nullptr) { + *memory_type_index_out = memory_index; + } + if (memory_property_flags_out != nullptr) { + *memory_property_flags_out = + memory_type_property_flags(physical, memory_index); + } + return VK_SUCCESS; + } + + ET_LOG( + Error, + "Failed to find tensor-compatible DEVICE_LOCAL|HOST_VISIBLE memory for " + "VGF boundary"); + return VK_ERROR_FEATURE_NOT_PRESENT; +} + VkResult create_tensor_unbound( VkDevice device, VkFormat format, @@ -1210,7 +1437,8 @@ VkResult allocate_buffer( VkDeviceSize size, VkBufferUsageFlags usage, VkBuffer* buffer, - VkDeviceMemory* memory) { + VkDeviceMemory* memory, + uint32_t* memory_type_index_out = nullptr) { VkBufferCreateInfo buffer_info = { .sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO, .pNext = nullptr, @@ -1264,6 +1492,9 @@ VkResult allocate_buffer( return result; } + if (memory_type_index_out != nullptr) { + *memory_type_index_out = memory_index; + } return VK_SUCCESS; } @@ -2284,6 +2515,11 @@ bool VgfRepr::process_vgf( } VkDeviceMemory tensor_memory = VK_NULL_HANDLE; bool owns_memory = true; + uint32_t tensor_memory_type_index = UINT32_MAX; + VkMemoryPropertyFlags tensor_memory_property_flags = 0; + bool tensor_memory_properties_known = false; + VkDeviceSize tensor_memory_allocation_size = + tensor_memory_requirements.memoryRequirements.size; auto* alias_backing = get_alias_backing(); if (alias_backing != nullptr) { if (!prepare_alias_memory( @@ -2294,17 +2530,44 @@ bool VgfRepr::process_vgf( destroy_tensor(vk_device, VK_NULL_HANDLE, tensor); return false; } + // The alias group owns the VkDeviceMemory and selected its type. + // The current IO must use that same type for cache maintenance. + tensor_memory_type_index = alias_backing->memory_type_index; + tensor_memory_allocation_size = alias_backing->allocation_size; + tensor_memory_property_flags = memory_type_property_flags( + vk_physical, tensor_memory_type_index); + tensor_memory_properties_known = + tensor_memory_type_index != UINT32_MAX && + (tensor_memory_property_flags & + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) != 0; } else { - const VkMemoryPropertyFlags aims = - VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT | - VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | - VK_MEMORY_PROPERTY_HOST_COHERENT_BIT; - result = allocate_memory( - vk_physical, - vk_device, - tensor_memory_requirements, - aims, - &tensor_memory); + if (vgf_zero_copy_io_build_enabled()) { + result = allocate_boundary_host_visible_memory( + vk_physical, + vk_device, + tensor_memory_requirements, + &tensor_memory, + &tensor_memory_type_index, + &tensor_memory_property_flags); + } else { + // Preserve the historical allocation contract when the + // optimization is not compiled in. + const VkMemoryPropertyFlags aims = + VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT | + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | + VK_MEMORY_PROPERTY_HOST_COHERENT_BIT; + result = allocate_memory( + vk_physical, + vk_device, + tensor_memory_requirements, + aims, + &tensor_memory, + &tensor_memory_type_index); + if (result == VK_SUCCESS) { + tensor_memory_property_flags = memory_type_property_flags( + vk_physical, tensor_memory_type_index); + } + } if (result != VK_SUCCESS) { destroy_tensor(vk_device, VK_NULL_HANDLE, tensor); ET_LOG( @@ -2313,6 +2576,20 @@ bool VgfRepr::process_vgf( i); return false; } + tensor_memory_properties_known = + tensor_memory_type_index != UINT32_MAX && + (tensor_memory_property_flags & + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) != 0; + ET_LOG( + Debug, + "VGF boundary tensor resource=%d memory_type=%u flags=0x%x " + "coherent=%d", + i, + tensor_memory_type_index, + static_cast(tensor_memory_property_flags), + static_cast( + (tensor_memory_property_flags & + VK_MEMORY_PROPERTY_HOST_COHERENT_BIT) != 0)); } result = bind_tensor_memory_and_create_view( vk_device, @@ -2349,6 +2626,21 @@ bool VgfRepr::process_vgf( true, is_in}); + // Only isolated tensor allocations are exposed for direct + // Method-boundary binding. Alias/image-backed resources keep + // the existing import/copy behavior. + IO& boundary_io = IOs.back(); + boundary_io.memory_allocation_size = tensor_memory_allocation_size; + boundary_io.memory_type_index = tensor_memory_type_index; + boundary_io.memory_property_flags = tensor_memory_property_flags; + boundary_io.memory_properties_known = + tensor_memory_properties_known; + boundary_io.owned_mapped_boundary_eligible = owns_memory && + !uses_alias_group && !image_aliasing && + tensor_memory_properties_known && + (tensor_memory_property_flags & + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) != 0; + auto zero_copy_metadata = make_vgf_zero_copy_io_metadata( /*is_model_boundary_resource=*/true, is_in, @@ -2399,6 +2691,9 @@ bool VgfRepr::process_vgf( } VkDeviceMemory buffer_memory = VK_NULL_HANDLE; bool owns_memory = true; + uint32_t buffer_memory_type_index = UINT32_MAX; + VkDeviceSize buffer_memory_allocation_size = + buffer_memory_requirements.memoryRequirements.size; auto* alias_backing = get_alias_backing(); if (alias_backing != nullptr) { if (!prepare_alias_memory( @@ -2409,6 +2704,8 @@ bool VgfRepr::process_vgf( destroy_buffer(vk_device, buffer); return false; } + buffer_memory_type_index = alias_backing->memory_type_index; + buffer_memory_allocation_size = alias_backing->allocation_size; } else { const VkMemoryPropertyFlags aims = VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT | @@ -2419,7 +2716,8 @@ bool VgfRepr::process_vgf( vk_device, buffer_memory_requirements, aims, - &buffer_memory); + &buffer_memory, + &buffer_memory_type_index); if (result != VK_SUCCESS) { destroy_buffer(vk_device, buffer); ET_LOG( @@ -2459,6 +2757,17 @@ bool VgfRepr::process_vgf( owns_memory, true, is_in}); + IO& boundary_buffer_io = IOs.back(); + boundary_buffer_io.memory_allocation_size = + buffer_memory_allocation_size; + boundary_buffer_io.memory_type_index = buffer_memory_type_index; + boundary_buffer_io.memory_property_flags = + memory_type_property_flags( + vk_physical, buffer_memory_type_index); + boundary_buffer_io.memory_properties_known = + buffer_memory_type_index != UINT32_MAX && + (boundary_buffer_io.memory_property_flags & + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) != 0; zero_copy_io_metadata.push_back(make_vgf_zero_copy_io_metadata( /*is_model_boundary_resource=*/true, is_in, @@ -2644,6 +2953,7 @@ bool VgfRepr::process_vgf( } VkBuffer staging_buffer = VK_NULL_HANDLE; VkDeviceMemory staging_memory = VK_NULL_HANDLE; + uint32_t staging_memory_type_index = UINT32_MAX; result = allocate_buffer( vk_physical, vk_device, @@ -2651,7 +2961,8 @@ bool VgfRepr::process_vgf( VK_BUFFER_USAGE_TRANSFER_SRC_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT, &staging_buffer, - &staging_memory); + &staging_memory, + &staging_memory_type_index); if (result != VK_SUCCESS) { ET_LOG( Error, @@ -2685,6 +2996,14 @@ bool VgfRepr::process_vgf( true, owns_image_memory, is_in}); + IO& staging_io = IOs.back(); + staging_io.memory_type_index = staging_memory_type_index; + staging_io.memory_property_flags = memory_type_property_flags( + vk_physical, staging_memory_type_index); + staging_io.memory_properties_known = + staging_memory_type_index != UINT32_MAX && + (staging_io.memory_property_flags & + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) != 0; zero_copy_io_metadata.push_back(make_vgf_zero_copy_io_metadata( /*is_model_boundary_resource=*/true, is_in, diff --git a/backends/arm/runtime/VGFSetup.h b/backends/arm/runtime/VGFSetup.h index e4512103f0b..31124d8604b 100644 --- a/backends/arm/runtime/VGFSetup.h +++ b/backends/arm/runtime/VGFSetup.h @@ -21,6 +21,7 @@ using executorch::runtime::CompileSpec; // We use the platform and runtime environment provided by the Vulkan delegate #include +#include #include #include #include @@ -53,6 +54,16 @@ typedef struct IO { bool owns_memory = true; bool owns_image_memory = true; bool is_input; + + // Describes the actual host-mapped boundary VkDeviceMemory: a tensor, + // storage buffer, or image staging buffer. Alias-backed IOs inherit the + // alias group's allocation properties. Independent of external-host-import + // metadata; only isolated tensor boundaries can be exposed to the Method. + VkDeviceSize memory_allocation_size = 0; + uint32_t memory_type_index = UINT32_MAX; + VkMemoryPropertyFlags memory_property_flags = 0; + bool memory_properties_known = false; + bool owned_mapped_boundary_eligible = false; } IO; typedef struct PersistentMappedMemory { @@ -210,6 +221,20 @@ class VgfRepr { return host_memory_import_capabilities_; } + // Expose only isolated VGF-owned tensor boundary allocations. The caller + // never owns the returned mapping. Cache maintenance is explicit because + // HOST_VISIBLE memory is not necessarily HOST_COHERENT on mobile GPUs. + executorch::runtime::Error get_owned_mapped_boundary_buffer( + bool input, + size_t argument_index, + VgfMappedBoundaryBuffer* buffer) const; + bool is_owned_mapped_io_pointer( + size_t io_index, + const void* pointer, + size_t logical_nbytes) const; + bool flush_io_host_writes(size_t io_index); + bool invalidate_io_host_reads(size_t io_index); + // Resource-management seam for execute(). VgfZeroCopyImportResult try_import_zero_copy_candidate( size_t io_index, diff --git a/backends/arm/runtime/targets.bzl b/backends/arm/runtime/targets.bzl index 896c1a6aa06..c6e352df48c 100644 --- a/backends/arm/runtime/targets.bzl +++ b/backends/arm/runtime/targets.bzl @@ -111,6 +111,7 @@ def define_common_targets(): "fbsource//third-party/vulkan-headers-1.4.343/v1.4.343/src:volk_arm_src", ], exported_headers = [ + "VGFBoundaryIO.h", "VGFExecutionStats.h", "VGFMutableAlias.h", "VGFNeuralStatistics.h", diff --git a/backends/arm/test/vgf_zero_copy_test.cpp b/backends/arm/test/vgf_zero_copy_test.cpp index a6af6b8c4f9..4564b9f0c7d 100644 --- a/backends/arm/test/vgf_zero_copy_test.cpp +++ b/backends/arm/test/vgf_zero_copy_test.cpp @@ -7,6 +7,7 @@ #include +#include #include #include @@ -21,6 +22,24 @@ namespace backends { namespace vgf { namespace { +TEST(VgfOwnedMappedBoundaryBufferTest, RequiresExactPointerAndLogicalSize) { + std::array storage{}; + const VgfMappedBoundaryBuffer buffer{ + .data = storage.data(), + .logical_nbytes = 64, + .capacity_nbytes = storage.size(), + .host_coherent = false, + .generation = 7, + }; + + EXPECT_TRUE(vgf_owned_mapped_buffer_matches(buffer, storage.data(), 64)); + EXPECT_FALSE(vgf_owned_mapped_buffer_matches(buffer, storage.data(), 63)); + EXPECT_FALSE(vgf_owned_mapped_buffer_matches(buffer, storage.data() + 1, 64)); + + VgfMappedBoundaryBuffer empty{}; + EXPECT_FALSE(vgf_owned_mapped_buffer_matches(empty, nullptr, 0)); +} + #if defined(VK_EXT_external_memory_host) template From 64c0e4e5c0c7bbdcc806fce5d7d5b8add3df4cb9 Mon Sep 17 00:00:00 2001 From: Elena Zhelezina Date: Thu, 5 Nov 2026 15:24:26 +0000 Subject: [PATCH 095/128] Arm backend: Fix provenance metadata for Unsqueeze (#24664) Fix provenance metadata for Unsqueeze cc @digantdesai @freddan80 @per @zingo @oscarandersson8218 @mansnils @Sebastian-Larsson @robell @rascani Signed-off-by: Elena Zhelezina --- .../_passes/unsqueeze_before_repeat_pass.py | 3 +- .../test_unsqueeze_before_repeat_pass.py | 79 +++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/backends/arm/_passes/unsqueeze_before_repeat_pass.py b/backends/arm/_passes/unsqueeze_before_repeat_pass.py index ed6aa82aad5..5be2cdae1c6 100644 --- a/backends/arm/_passes/unsqueeze_before_repeat_pass.py +++ b/backends/arm/_passes/unsqueeze_before_repeat_pass.py @@ -1,4 +1,4 @@ -# Copyright 2024-2025 Arm Limited and/or its affiliates. +# Copyright 2024-2026 Arm Limited and/or its affiliates. # All rights reserved. # # This source code is licensed under the BSD-style license found in the @@ -56,6 +56,7 @@ def call(self, graph_module: torch.fx.GraphModule): graph_module.graph, exir_ops.edge.aten.view_copy.default, (node.all_input_nodes[0], new_shape), + from_node=node, ) node.replace_input_with(node.all_input_nodes[0], view_node) modified_graph = True diff --git a/backends/arm/test/passes/test_unsqueeze_before_repeat_pass.py b/backends/arm/test/passes/test_unsqueeze_before_repeat_pass.py index 2025bbe8435..2e413d1a546 100644 --- a/backends/arm/test/passes/test_unsqueeze_before_repeat_pass.py +++ b/backends/arm/test/passes/test_unsqueeze_before_repeat_pass.py @@ -9,6 +9,8 @@ from executorch.backends.arm._passes import UnsqueezeBeforeRepeatPass from executorch.backends.arm.test import common from executorch.backends.arm.test.tester.test_pipeline import PassPipeline +from executorch.exir import EdgeCompileConfig, to_edge +from executorch.exir.dialects._ops import ops as exir_ops pipeline_input_t = Tuple[torch.Tensor, ...] test_case_t = Tuple[ @@ -57,3 +59,80 @@ def test_unsqueeze_before_repeat_tosa_FP(test_data: test_case_t): ) pipeline.pop_stage(-1) # Do not compare output pipeline.run() + + +class _RepeatBlock(torch.nn.Module): + """Provide an actual named module boundary for exported FX provenance.""" + + def forward(self, x: torch.Tensor) -> torch.Tensor: + return x.repeat(2, 1, 1, 1) + + +class _NestedRepeat(torch.nn.Module): + def __init__(self) -> None: + super().__init__() + self.repeat_block = _RepeatBlock() + + def forward(self, x: torch.Tensor) -> torch.Tensor: + return self.repeat_block(x) + + +def test_unsqueeze_before_repeat_preserves_provenance() -> None: + """The new view retains repeat provenance, but gets its own + shape/qparams. + """ + model = _NestedRepeat().eval() + inputs = (torch.randn(2, 3, 4),) + ep = to_edge( + torch.export.export(model, inputs), + compile_config=EdgeCompileConfig(_check_ir_validity=False), + ).exported_program() + gm = ep.graph_module + repeats = [ + n for n in gm.graph.nodes if n.target == exir_ops.edge.aten.repeat.default + ] + assert len(repeats) == 1 + repeat = repeats[0] + + original_stack = repeat.meta.get("nn_module_stack") + assert original_stack, "Export must preserve Repeat module provenance" + source_fn_stack = [("repeat_block", _RepeatBlock)] + repeat.meta["source_fn_stack"] = source_fn_stack + repeat.meta["stack_trace"] = "original repeat location" + repeat.meta["debug_handle"] = 123456 + # These refer to Repeat's inputs/output, not to the new view. Inheriting + # them would assign an incorrect quantization annotation to the view. + repeat.meta["input_qparams"] = {0: "repeat-input-qparams"} + repeat.meta["output_qparams"] = {0: "repeat-output-qparams"} + + result = UnsqueezeBeforeRepeatPass()(gm) + assert result is not None + assert result.modified + repeats_after = [ + n + for n in result.graph_module.graph.nodes + if n.target == exir_ops.edge.aten.repeat.default + ] + assert len(repeats_after) == 1 + view = repeats_after[0].args[0] + assert isinstance(view, torch.fx.Node) + assert view.target == exir_ops.edge.aten.view_copy.default + assert view.args[1] == [1, 2, 3, 4] + + assert view.meta.get("nn_module_stack") == original_stack + assert view.meta.get("source_fn_stack") == source_fn_stack + assert view.meta.get("debug_handle") == 123456 + assert "original repeat location" in view.meta.get("stack_trace", "") + assert not view.meta.get("input_qparams") + assert not view.meta.get("output_qparams") + assert tuple(view.meta["val"].shape) == (1, 2, 3, 4) + assert tuple(repeats_after[0].meta["val"].shape) == (2, 2, 3, 4) + + # Keep both semantics and idempotence unchanged. + actual = result.graph_module(*inputs) + if isinstance(actual, (tuple, list)): + actual = actual[0] + torch.testing.assert_close(actual, model(*inputs)) + repeated = UnsqueezeBeforeRepeatPass()(result.graph_module) + assert repeated is not None + assert not repeated.modified From d1f597e709e57531fd933b15f524a3bd81ad0fea Mon Sep 17 00:00:00 2001 From: Elena Zhelezina Date: Thu, 5 Nov 2026 15:25:04 +0000 Subject: [PATCH 096/128] Arm backend: Update tag for ML SDK emulation layer. (#24662) We need to update it, otherwise we build 0.9 version of the emulation layer. cc @digantdesai @freddan80 @per @zingo @oscarandersson8218 @mansnils @Sebastian-Larsson @robell @rascani Signed-off-by: Elena Zhelezina --- .../arm/scripts/setup-mlsdk-from-source.sh | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/backends/arm/scripts/setup-mlsdk-from-source.sh b/backends/arm/scripts/setup-mlsdk-from-source.sh index 5b20da8bfff..bae696b3da4 100755 --- a/backends/arm/scripts/setup-mlsdk-from-source.sh +++ b/backends/arm/scripts/setup-mlsdk-from-source.sh @@ -15,12 +15,13 @@ root_dir="${et_dir}/examples/arm/arm-scratch" setup_path_script="" mlsdk_manifest_dir="ml-sdk-for-vulkan-manifest" mlsdk_manifest_url="${MLSDK_MANIFEST_URL:-https://github.com/arm/ai-ml-sdk-manifest.git}" -mlsdk_manifest_tag="${MLSDK_MANIFEST_TAG:-refs/tags/v2026.09.0}" # Keep this in sync with backends/arm/requirements-arm-vgf.txt and backends/arm/requirements-arm-vgf-runtime.txt +mlsdk_manifest_tag="${MLSDK_MANIFEST_TAG:-refs/tags/v2026.12.0.dev20261027}" # Keep this in sync with backends/arm/requirements-arm-vgf.txt and backends/arm/requirements-arm-vgf-runtime.txt enable_model_converter=0 enable_vgf_lib=0 enable_emulation_layer=0 enable_vulkan_sdk=0 +expected_emulation_layer_version="0.11.0" source "${script_dir}/utils.sh" source "${script_dir}/vulkan_utils.sh" @@ -241,6 +242,41 @@ function download_ai_mlsdk_manifest() { mlsdk_sync_manifest "${manifest_dir}" } +function validate_emulation_layer_source_version() { + local manifest_dir="$1" + local emulation_layer_dir="${manifest_dir}/sw/emulation-layer" + local pyproject="${emulation_layer_dir}/pyproject.toml" + local source_version="" + + if [[ ! -f "${pyproject}" ]]; then + log_step "mlsdk" \ + "Unable to validate emulation-layer source version: ${pyproject} not found" + return 1 + fi + + source_version="$( + sed -nE 's/^[[:space:]]*fallback_version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' \ + "${pyproject}" | head -n 1 + )" + + if [[ -z "${source_version}" ]]; then + log_step "mlsdk" \ + "Unable to determine emulation-layer source version from ${pyproject}" + return 1 + fi + + if [[ "${source_version}" != "${expected_emulation_layer_version}" ]]; then + log_step "mlsdk" \ + "Emulation-layer source version mismatch: expected ${expected_emulation_layer_version}, found ${source_version}." + log_step "mlsdk" \ + "The selected MLSDK manifest (${mlsdk_manifest_tag}) is not in sync with backends/arm/requirements-arm-vgf.txt." + return 1 + fi + + log_step "mlsdk" \ + "Validated emulation-layer source version ${source_version}" +} + function setup_mlsdk_source() { local manifest_dir="$1" @@ -255,6 +291,10 @@ function setup_mlsdk_source() { log_step "mlsdk" "Syncing MLSDK manifest into ${manifest_dir}" download_ai_mlsdk_manifest "${manifest_dir}" + if [[ "${enable_emulation_layer}" -eq 1 ]]; then + validate_emulation_layer_source_version "${manifest_dir}" + fi + pushd "${manifest_dir}" >/dev/null || return 1 local parallel_jobs parallel_jobs="$(get_parallel_jobs)" From 14f12dad8a76a3ac5d2ba37682944a501cb5f1b7 Mon Sep 17 00:00:00 2001 From: usamahz Date: Thu, 5 Nov 2026 18:04:54 +0000 Subject: [PATCH 097/128] Arm backend: Fix MobileSAM setup and FVP measurements (#24118) ### Summary Fix the MobileSAM CPU setup failing on an unavailable TorchAO nightly by opting into tested PyTorch 2.14.0 CPU and TorchAO 0.18.0 release wheels. The README enables `EXECUTORCH_USE_RELEASE_CPU_DEPS=1` for its installation command; normal Torch/TorchAO dependency defaults, CUDA, and source builds retain their existing selection. Align FlatBuffers with TOSA's requirement so the example uses one environment. Make the shared FVP launcher report unsupported CPU timing, CPU/NPU ratios, and fast-mode NPU counters as unavailable, including device and delegate PMU counters. Timed runs retain simulated NPU counters, including valid zeros. This covers runners built without semihosting and preserves hardware reporting. Keep the existing example flow with optional `run.sh --timed`. ### Validation - November 2: the README installation/setup sequence and MobileSAM example passed from a fresh Python 3.12 environment on Ubuntu 24.04 amd64 in Docker under Apple silicon emulation; mask IoU 0.977785. - November 4, after rebasing: 33 dependency and FVP launcher tests passed. The Arm pre-push checks passed for both commits, including lint, commit/license checks, operator documentation, and public API manifests. - November 4: fresh qlinear export and non-semihosting U85 runner build passed. Both fast and timed FVP runs passed bundled output verification. Fast mode suppressed unavailable measurements; timed mode retained 3,127 NPU cycles, matching device/delegate counters, and valid zeros. The complete MobileSAM README flow was not rerun after this rebase. Authored with assistance from Codex. cc @digantdesai @freddan80 @per @zingo @oscarandersson8218 @mansnils @Sebastian-Larsson @robell @rascani --------- Signed-off-by: Usamah Zaheer --- .ci/scripts/tests/test_cu134_dependencies.py | 88 +++++++++++- backends/arm/requirements-arm-tosa.txt | 2 +- backends/arm/scripts/run_fvp.sh | 21 ++- backends/arm/test/test_run_fvp.py | 134 ++++++++++++++++-- .../arm/executor_runner/arm_perf_monitor.cpp | 95 ++++++------- .../README.md | 6 +- .../run.sh | 13 +- install_requirements.py | 38 +++-- setup.py | 13 +- 9 files changed, 325 insertions(+), 85 deletions(-) diff --git a/.ci/scripts/tests/test_cu134_dependencies.py b/.ci/scripts/tests/test_cu134_dependencies.py index 224d5dcafeb..a8af05f11d7 100644 --- a/.ci/scripts/tests/test_cu134_dependencies.py +++ b/.ci/scripts/tests/test_cu134_dependencies.py @@ -40,10 +40,11 @@ def install_commands( nightly=True, system="Linux", python_version=(3, 14), + env=None, ): self.utils.determine_torch_url.cache_clear() with ( - patch.dict(os.environ, {}, clear=True), + patch.dict(os.environ, env or {}, clear=True), patch.object( self.utils, "_get_cuda_version", @@ -133,6 +134,61 @@ def test_no_cuda_keeps_default_pins(self): self.assertIn("torchvision==0.29.0", domains) self.assertIn("https://download.pytorch.org/whl/test/cpu", core) + def test_cpu_release_dependencies_are_opt_in(self): + for machine, python_version, audio in ( + ("x86_64", (3, 12), "2.11.0"), + ("aarch64", (3, 12), "2.11.0"), + ("x86_64", (3, 15), "2.11.1"), + ("aarch64", (3, 15), "2.11.1"), + ): + with self.subTest(machine=machine, python_version=python_version): + core, _, domains, examples = self.install_commands( + None, + machine, + python_version=python_version, + env={"EXECUTORCH_USE_RELEASE_CPU_DEPS": "1"}, + ) + self.assertIn("torch==2.14.0", core) + self.assertIn(f"torchao=={self.installer.TORCHAO_STABLE_VERSION}", core) + self.assertIn("torchvision==0.29.0", domains) + self.assertIn(f"torchaudio=={audio}", domains) + for command in (core, domains, examples): + self.assertIn("https://download.pytorch.org/whl/cpu", command) + self.assertFalse( + any("/nightly" in arg or "/test/" in arg for arg in command) + ) + + def test_cpu_release_option_keeps_cuda_pins(self): + for cuda in ((12, 6), (13, 0), (13, 2), (13, 4)): + for machine in ("x86_64", "aarch64"): + with self.subTest(cuda=cuda, machine=machine): + self.assertEqual( + self.install_commands(cuda, machine), + self.install_commands( + cuda, + machine, + env={"EXECUTORCH_USE_RELEASE_CPU_DEPS": "1"}, + ), + ) + + def test_cpu_source_build_keeps_nightly_torchao(self): + for flag in ( + "EXECUTORCH_BUILD_KERNELS_TORCHAO", + "TORCHAO_BUILD_EXPERIMENTAL_MPS", + ): + with self.subTest(flag=flag): + core, local, domains, _ = self.install_commands( + None, + env={"EXECUTORCH_USE_RELEASE_CPU_DEPS": "1", flag: "1"}, + ) + self.assertIn( + f"torchao=={self.installer.TORCHAO_NIGHTLY_VERSION}", core + ) + self.assertIn("https://download.pytorch.org/whl/nightly/cpu", core) + for command in (core, domains): + self.assertIn("https://download.pytorch.org/whl/test/cpu", command) + self.assertIn("third-party/ao", local) + def test_windows_does_not_select_cu134(self): core, _, domains, _ = self.install_commands((13, 4), system="Windows") self.assertIn("torch==2.14.0", core) @@ -149,6 +205,7 @@ def test_python315_cpu_domains_use_available_torchaudio(self): audio = "2.11.1" if version >= (3, 15) else "2.11.0" self.assertIn(f"torchaudio=={audio}", domains) self.assertIn("torchvision==0.29.0", domains) + self.assertIn("https://download.pytorch.org/whl/test/cpu", domains) def test_python315_cuda_domains_use_available_torchaudio(self): for machine in ("x86_64", "aarch64"): @@ -231,6 +288,35 @@ def torchao_requirement(self): ) return namespace["_torchao_requirement"]() + def test_cpu_release_metadata_is_opt_in(self): + for value in (None, "0", "1"): + with self.subTest(value=value): + self.utils.determine_torch_url.cache_clear() + env = ( + {} if value is None else {"EXECUTORCH_USE_RELEASE_CPU_DEPS": value} + ) + with ( + patch.dict(os.environ, env, clear=True), + patch.object( + self.utils, + "_get_cuda_version", + side_effect=RuntimeError("no nvcc"), + ), + patch.object( + self.installer.platform, "system", return_value="Linux" + ), + ): + version = ( + self.installer.TORCHAO_STABLE_VERSION + if value == "1" + else self.installer.TORCHAO_NIGHTLY_VERSION + ) + major, minor = (int(part) for part in version.split(".")[:2]) + self.assertEqual( + self.torchao_requirement(), + f"torchao>={version},<{major}.{minor + 1}", + ) + def test_package_install_preserves_source_pinned_torchao(self): with patch.dict(sys.modules, {"install_requirements": self.installer}): package_installer = load_module("install_executorch") diff --git a/backends/arm/requirements-arm-tosa.txt b/backends/arm/requirements-arm-tosa.txt index 675d49df70a..c3c9b7e82b8 100644 --- a/backends/arm/requirements-arm-tosa.txt +++ b/backends/arm/requirements-arm-tosa.txt @@ -6,6 +6,6 @@ # These dependencies need to match pyproject.toml ml_dtypes == 0.5.1 -flatbuffers == 24.3.25 +flatbuffers == 25.2.10 pytest-timeout == 2.4.0 tosa-tools == 2026.5.0 diff --git a/backends/arm/scripts/run_fvp.sh b/backends/arm/scripts/run_fvp.sh index 57f1c7eab4c..72d7a61eb64 100755 --- a/backends/arm/scripts/run_fvp.sh +++ b/backends/arm/scripts/run_fvp.sh @@ -90,13 +90,18 @@ hash ${fvp_model} \ if [[ ${target} == cortex-m* ]]; then # Cortex-M CPU-only; the NPU is unused but the FVP still needs a value. num_macs=128 + ethosu_fast=1 else num_macs=$(echo ${target} | cut -d - -f 3) fi echo "--------------------------------------------------------------------------------" echo "Running ${elf_file} for ${target} run with FVP:${fvp_model} num_macs:${num_macs} timeout:${timeout}" -echo "The Corstone FVPs are cycle accurate for the NPU part, but note that the CPU performance is not cycle accurate." +if [[ ${ethosu_fast} == 1 ]]; then + echo "FVP CPU timing, CPU/NPU ratios and NPU PMU counters: N/A (--fast)." +else + echo "FVP CPU timing and CPU/NPU ratios: N/A; NPU PMU counters are simulated." +fi echo "--------------------------------------------------------------------------------" # Check if stdbuf is intalled and use stdbuf -oL together with tee below to make the output @@ -117,6 +122,20 @@ if [[ ${target} == ethos-u55-* ]]; then log_filter+="; /${known_u55_diagnostic}/s/^\[critical\]/[warning]/" fi +# Non-semihosting runners cannot receive the simulation mode as an argument. +# Qualify their console measurements here, leaving errors and execution counts intact. +perf_log='^I \[executorch:arm_perf_monitor\.cpp:[0-9]+ StopMeasurements\(\)\] ' +cpu_metrics='(Profiler report, CPU cycles per operator:|ethos-u : cycle_cnt :|Operator\(s\) total:' +cpu_metrics+='|Inference runtime:|Inference (CPU|NPU) ratio:|cpu_wait_for_npu_cntr :' +cpu_metrics+='|NOTE: CPU\/NPU ratios|CPU timing and CPU\/NPU ratios:)' +log_filter+="; /${perf_log}${cpu_metrics}/d" +if [[ ${ethosu_fast} == 1 ]]; then + npu_metrics='(Ethos-U (per-delegate )?PMU report:|ethosu_pmu_(cycle_cntr|cntr[0-9]+) :' + npu_metrics+='|Ethos-U delegate [0-9]+ PMU (cycles|counter [0-9]+):' + npu_metrics+='|Ethos-U (delegate [0-9]+ )?NPU 0x[[:xdigit:]]+ PMU (cycles|counter [0-9]+ event [0-9]+):)' + log_filter+="; /${perf_log}${npu_metrics}/d" +fi + run_simulation() { local -a pipeline_status local simulation_status diff --git a/backends/arm/test/test_run_fvp.py b/backends/arm/test/test_run_fvp.py index 301bba57d6b..7f89420f679 100644 --- a/backends/arm/test/test_run_fvp.py +++ b/backends/arm/test/test_run_fvp.py @@ -31,13 +31,18 @@ def fvp_test_environment(tmp_path: Path) -> tuple[Path, Path, dict[str, str]]: bin_dir = tmp_path / "bin" bin_dir.mkdir() - fvp = bin_dir / "FVP_Corstone_SSE-300_Ethos-U55" - fvp.write_text( - "#!/usr/bin/env bash\n" - "printf '%s\\n' \"${FVP_TEST_OUTPUT-}\"\n" - 'exit "${FVP_TEST_STATUS-0}"\n' - ) - fvp.chmod(0o755) + for name in ( + "FVP_Corstone_SSE-300_Ethos-U55", + "FVP_Corstone_SSE-300_Ethos-U65", + "FVP_Corstone_SSE-320", + ): + fvp = bin_dir / name + fvp.write_text( + "#!/usr/bin/env bash\n" + "printf '%s\\n' \"${FVP_TEST_OUTPUT-}\"\n" + 'exit "${FVP_TEST_STATUS-0}"\n' + ) + fvp.chmod(0o755) env = os.environ.copy() env["PATH"] = f"{bin_dir}:{env['PATH']}" @@ -48,6 +53,9 @@ def run_fvp( test_environment: tuple[Path, Path, dict[str, str]], output: str, status: int = 0, + *, + target: str = "ethos-u55-128", + args: tuple[str, ...] = (), ) -> subprocess.CompletedProcess[str]: """Run the test copy of run_fvp.sh with configured fake FVP output.""" script, elf, env = test_environment @@ -57,7 +65,8 @@ def run_fvp( "/bin/bash", str(script), f"--elf={elf}", - "--target=ethos-u55-128", + f"--target={target}", + *args, ], capture_output=True, env=env, @@ -156,3 +165,112 @@ def test_fvp_known_diagnostic_does_not_hide_other_errors( ) assert result.returncode == 1 + + +_MONITOR_PREFIX = "I [executorch:arm_perf_monitor.cpp:300 StopMeasurements()] " +_CPU_REPORT = ( + "Profiler report, CPU cycles per operator:", + "ethos-u : cycle_cnt : 42 cycles (42.00 per inference)", + "Operator(s) total: 42 CPU cycles (42.00 per inference)", + "Inference runtime: 100 CPU cycles total (100.00 per inference)", + "Inference CPU ratio: 100.00 %", + "Inference NPU ratio: 0.00 %", + "cpu_wait_for_npu_cntr : 0 CPU cycles (0.00 per inference)", + "NOTE: CPU/NPU ratios use CPU-clock time inside NPU callbacks, not NPU utilization", + "CPU timing and CPU/NPU ratios: N/A (CPU PMU is not present)", +) +_NPU_REPORT = ( + "Ethos-U PMU report:", + "ethosu_pmu_cycle_cntr : 0 (0.00 per inference)", + "ethosu_pmu_cntr4 : 123 (123.00 per inference)", + "Ethos-U per-delegate PMU report:", + "Ethos-U delegate 0 PMU cycles: 0", + "Ethos-U delegate 0 PMU counter 4: 123", + "Ethos-U NPU 0x5000a000 PMU cycles: 0 (overflow: 0)", + "Ethos-U NPU 0x5000a000 PMU counter 0 event 19: 0 (overflow: 0)", + "Ethos-U delegate 0 NPU 0x5000a000 PMU counter 4 event 9: 123 (overflow: 0)", +) +_EXECUTION_REPORT = ( + "NPU Inferences : 1", + "NPU delegations: 1 (1.00 per inference)", + "Ethos-U NPU 0x5000a000: product 85, invocations 1, failures 0", + "Ethos-U delegate 0: 1 backend invocations, 1 NPU invocations", + "Ethos-U IO copy calls: 2 (2.00 per inference)", + "Ethos-U IO copy bytes: 1024 bytes (1024.00 per inference)", + "Ethos-U delegate 0 input copy: 1 calls, 512 bytes", +) +_FAST_BANNER = "FVP CPU timing, CPU/NPU ratios and NPU PMU counters: N/A (--fast)." +_TIMED_BANNER = ( + "FVP CPU timing and CPU/NPU ratios: N/A; NPU PMU counters are simulated." +) + + +@pytest.mark.parametrize("target", ["ethos-u55-128", "ethos-u65-256", "ethos-u85-256"]) +@pytest.mark.parametrize("fast", [False, True]) +def test_fvp_reports_only_available_measurements( + fvp_test_environment: tuple[Path, Path, dict[str, str]], + target: str, + fast: bool, +) -> None: + """Generic runners need no CLI mode; timed NPU zeros remain valid values.""" + unrelated = "I [executorch:other.cpp:300 StopMeasurements()] Inference runtime: 100" + output = "\n".join( + _MONITOR_PREFIX + message + for message in (*_CPU_REPORT, *_NPU_REPORT, *_EXECUTION_REPORT) + ) + result = run_fvp( + fvp_test_environment, + f"{output}\n{unrelated}\nModel run: 1", + target=target, + args=("--fast",) if fast else (), + ) + + assert result.returncode == 0 + assert result.stdout.count(_FAST_BANNER if fast else _TIMED_BANNER) == 1 + for message in _CPU_REPORT: + assert _MONITOR_PREFIX + message not in result.stdout + for message in _NPU_REPORT: + assert (_MONITOR_PREFIX + message in result.stdout) is not fast + for message in _EXECUTION_REPORT: + assert _MONITOR_PREFIX + message in result.stdout + assert unrelated in result.stdout + assert "Model run: 1" in result.stdout + + +def test_cortex_m_reports_its_forced_fast_mode( + fvp_test_environment: tuple[Path, Path, dict[str, str]], +) -> None: + """The CPU-only path forces --fast even when the caller omits the flag.""" + _, elf, _ = fvp_test_environment + bundle = elf.parent / "model.bpte" + bundle.write_bytes(b"") + result = run_fvp( + fvp_test_environment, + f"{_MONITOR_PREFIX}{_NPU_REPORT[1]}\nTest_result: PASS", + target="cortex-m55", + args=(f"--bundle={bundle}",), + ) + + assert result.returncode == 0 + assert _FAST_BANNER in result.stdout + assert _TIMED_BANNER not in result.stdout + assert _MONITOR_PREFIX + _NPU_REPORT[1] not in result.stdout + assert "Test_result: PASS" in result.stdout + + +@pytest.mark.parametrize("level", ["E", "F"]) +def test_fvp_measurement_filter_preserves_errors( + fvp_test_environment: tuple[Path, Path, dict[str, str]], + level: str, +) -> None: + """A metric-shaped diagnostic must still fail the run and remain visible.""" + diagnostic = "\n".join( + f"{level}{_MONITOR_PREFIX[1:]}{message}" + for message in (_NPU_REPORT[1], _NPU_REPORT[-1]) + ) + result = run_fvp( + fvp_test_environment, f"{diagnostic}\nModel run: 1", args=("--fast",) + ) + + assert result.returncode == 1 + assert diagnostic in result.stdout diff --git a/examples/arm/executor_runner/arm_perf_monitor.cpp b/examples/arm/executor_runner/arm_perf_monitor.cpp index c45869b11a4..1f38079b227 100644 --- a/examples/arm/executor_runner/arm_perf_monitor.cpp +++ b/examples/arm/executor_runner/arm_perf_monitor.cpp @@ -268,7 +268,7 @@ void ConsumeMeasurements() { } void StartMeasurements() { -#if defined(__ARM_ARCH_8_1M_MAIN__) +#if defined(__PMU_PRESENT) && (__PMU_PRESENT == 1U) // StopMeasurements() disables the cycle counter after each measurement. // Server mode starts a new measurement for every inference. ARM_PMU_Enable(); @@ -323,6 +323,15 @@ void StopMeasurements(size_t num_inferences) { "wait cycles, and CPU/NPU ratios are incorrect"); } uint64_t cycle_count = arm_pmu_cycles() - ethosu_ArmCycleCountStart; + const bool cpu_timing_available = +#if defined(__PMU_PRESENT) && (__PMU_PRESENT == 1U) + true; +#else + false; +#endif + if (!cpu_timing_available) { + ET_LOG(Info, "CPU timing and CPU/NPU ratios: N/A (CPU PMU is not present)"); + } // Number of comand streams handled by the NPU ET_LOG( @@ -332,15 +341,14 @@ void StopMeasurements(size_t num_inferences) { "NPU delegations: %" PRIu32 " (%.2f per inference)", ethosu_delegation_count, (double)ethosu_delegation_count / num_inferences); - ET_LOG(Info, "Profiler report, CPU cycles per operator:"); - // This is number of CPU cycles for the ethos-u operator from start to finish - // in the framework If there is more then one commandstream the time is added - // together - ET_LOG( - Info, - "ethos-u : cycle_cnt : %" PRIu64 " cycles (%.2f per inference)", - ethosu_ArmBackendExecuteCycleCount, - (double)ethosu_ArmBackendExecuteCycleCount / num_inferences); + if (cpu_timing_available) { + ET_LOG(Info, "Profiler report, CPU cycles per operator:"); + ET_LOG( + Info, + "ethos-u : cycle_cnt : %" PRIu64 " cycles (%.2f per inference)", + ethosu_ArmBackendExecuteCycleCount, + (double)ethosu_ArmBackendExecuteCycleCount / num_inferences); + } #if defined(ET_ARM_ETHOSU_PROFILE_IO_COPIES) const uint64_t io_copy_calls = ethosu_inputCopyStats.calls + ethosu_outputCopyStats.calls; @@ -367,50 +375,41 @@ void StopMeasurements(size_t num_inferences) { ethosu_outputCopyStats.calls, ethosu_outputCopyStats.bytes); #endif - // We could print a list of the cycles used by the other delegates here in the - // future but now we only print ethos-u: this means that "Operator(s) total: - // ..." will be the same number as ethos-u : cycle_cnt and not the sum of all - ET_LOG( - Info, - "Operator(s) total: %" PRIu64 " CPU cycles (%.2f per inference)", - ethosu_ArmBackendExecuteCycleCount, - (double)ethosu_ArmBackendExecuteCycleCount / num_inferences); - // Total CPU cycles used in the executorch method->execute() - // Other delegates and no delegates are counted in this - ET_LOG( - Info, - "Inference runtime: %" PRIu64 " CPU cycles total (%.2f per inference)", - cycle_count, - (double)cycle_count / num_inferences); - - ET_LOG( - Info, - "NOTE: CPU cycle values and ratio calculations require FPGA and identical CPU/NPU frequency"); - - // Avoid division with zero if arm_pmu_cycles() is not enabled properly. - if (cycle_count == 0) { - ET_LOG(Info, "Inference CPU ratio: ?.?? %%"); - ET_LOG(Info, "Inference NPU ratio: ?.?? %%"); - } else { + // Only the Ethos-U backend is included in the operator total. + if (cpu_timing_available) { ET_LOG( Info, - "Inference CPU ratio: %.2f %%", - 100.0 * (cycle_count - ethosu_ArmWhenNPURunCycleCount) / cycle_count); + "Operator(s) total: %" PRIu64 " CPU cycles (%.2f per inference)", + ethosu_ArmBackendExecuteCycleCount, + (double)ethosu_ArmBackendExecuteCycleCount / num_inferences); ET_LOG( Info, - "Inference NPU ratio: %.2f %%", - 100.0 * ethosu_ArmWhenNPURunCycleCount / cycle_count); + "Inference runtime: %" PRIu64 " CPU cycles total (%.2f per inference)", + cycle_count, + (double)cycle_count / num_inferences); + if (cycle_count == 0 || ethosu_ArmWhenNPURunCycleCount > cycle_count) { + ET_LOG(Info, "Inference CPU ratio: N/A"); + ET_LOG(Info, "Inference NPU ratio: N/A"); + } else { + ET_LOG( + Info, + "NOTE: CPU/NPU ratios use CPU-clock time inside NPU callbacks, not NPU utilization"); + ET_LOG( + Info, + "Inference CPU ratio: %.2f %%", + 100.0 * (cycle_count - ethosu_ArmWhenNPURunCycleCount) / cycle_count); + ET_LOG( + Info, + "Inference NPU ratio: %.2f %%", + 100.0 * ethosu_ArmWhenNPURunCycleCount / cycle_count); + } + ET_LOG( + Info, + "cpu_wait_for_npu_cntr : %" PRIu64 " CPU cycles (%.2f per inference)", + ethosu_ArmWhenNPURunCycleCount, + (double)ethosu_ArmWhenNPURunCycleCount / num_inferences); } - // CPU cycles used by NPU, e.g. number of CPU cycles used between - // ethosu_inference_begin() and ethosu_inference_end() - // If there is more then one commandstream the time is added together - ET_LOG( - Info, - "cpu_wait_for_npu_cntr : %" PRIu64 " CPU cycles (%.2f per inference)", - ethosu_ArmWhenNPURunCycleCount, - (double)ethosu_ArmWhenNPURunCycleCount / num_inferences); - #if defined(ET_ARM_ETHOSU_PMU_STATS) ET_LOG(Info, "Ethos-U PMU report:"); for (size_t device = 0; device < ethosu_deviceCount; ++device) { diff --git a/examples/arm/mobilesam_prompt_segmentation_example_ethos_u/README.md b/examples/arm/mobilesam_prompt_segmentation_example_ethos_u/README.md index ee2359c171d..06681fb7d94 100644 --- a/examples/arm/mobilesam_prompt_segmentation_example_ethos_u/README.md +++ b/examples/arm/mobilesam_prompt_segmentation_example_ethos_u/README.md @@ -10,12 +10,14 @@ in the exported model. Changing the prompt requires re-exporting the model. ## Run It +Use Python 3.12 with its `venv` and development packages, a C++ compiler, +Ninja, Git, curl, pkg-config, libglib2.0, libgomp, and xz-utils. From the ExecuTorch repository root: ```bash python3.12 -m venv .venv source .venv/bin/activate -./install_executorch.sh --optional-dependency ethos_u +EXECUTORCH_USE_RELEASE_CPU_DEPS=1 CMAKE_GENERATOR=Ninja ./install_executorch.sh --optional-dependency ethos_u ./examples/arm/setup.sh --i-agree-to-the-contained-eula ./examples/arm/mobilesam_prompt_segmentation_example_ethos_u/run.sh ``` @@ -25,6 +27,8 @@ The final command performs the complete flow and prints `arm_test/mobilesam/result/fvp_comparison.png` +Add `--timed` to `run.sh` to collect simulated NPU counters. + ## What It Does 1. Fetches the pinned official MobileSAM source and checkpoint outside the diff --git a/examples/arm/mobilesam_prompt_segmentation_example_ethos_u/run.sh b/examples/arm/mobilesam_prompt_segmentation_example_ethos_u/run.sh index 511284d2ba7..ce7a87bf708 100755 --- a/examples/arm/mobilesam_prompt_segmentation_example_ethos_u/run.sh +++ b/examples/arm/mobilesam_prompt_segmentation_example_ethos_u/run.sh @@ -6,11 +6,13 @@ set -euo pipefail -if (($#)); then - echo "This example has one supported configuration; run it without arguments." +fvp_args=(--fast --timeout=300) +if [[ $# -eq 1 && $1 == --timed ]]; then + fvp_args=(--timeout=7200) +elif (($#)); then + echo "Usage: $0 [--timed]" >&2 exit 2 fi - example_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) repo_root=$(cd "${example_dir}/../../.." && pwd) work_dir="${repo_root}/arm_test/mobilesam" @@ -51,10 +53,9 @@ echo "[4/4] Run on FVP and validate the output" backends/arm/scripts/run_fvp.sh \ --elf="${runner_dir}/arm_executor_runner" \ --target=ethos-u85-256 \ - --timeout=300 \ --semihosting-cwd="${io_dir}" \ - '--semihosting-cmd-line=executor_runner -i input.bin -o output' \ - --fast | tee "${work_dir}/fvp.log" + "--semihosting-cmd-line=executor_runner -i input.bin -o output" \ + "${fvp_args[@]}" | tee "${work_dir}/fvp.log" python3 "${example_dir}/runtime/visualize_fvp_output.py" echo "MobileSAM example: PASS" diff --git a/install_requirements.py b/install_requirements.py index 285c4bf281f..0634b81a1ee 100644 --- a/install_requirements.py +++ b/install_requirements.py @@ -1,5 +1,5 @@ # Copyright (c) Meta Platforms, Inc. and affiliates. -# Copyright 2024-25 Arm Limited and/or its affiliates. +# Copyright 2024-2026 Arm Limited and/or its affiliates. # All rights reserved. # # This source code is licensed under the BSD-style license found in the @@ -17,6 +17,7 @@ # This will be dynamically set based on CUDA availability and CUDA backend enabled/disabled. TORCH_URL_BASE = "https://download.pytorch.org/whl/test" TORCHAO_URL_BASE = "https://download.pytorch.org/whl/nightly" +TORCHAO_STABLE_VERSION = "0.18.0" TORCHAO_NIGHTLY_VERSION = "0.19.0.dev20260907" CU134_TORCHAO_NIGHTLY_VERSION = "0.19.0.dev20260907" # Newest rocm7.2 TorchAO nightly with compiled kernels (cp310-abi3); later ROCm nightlies @@ -39,6 +40,23 @@ def torchao_from_source(): ) +def get_torchao_version(torch_url): + if ( + os.environ.get("EXECUTORCH_USE_RELEASE_CPU_DEPS") == "1" + and torch_url.endswith("/cpu") + and not torchao_from_source() + ): + return TORCHAO_STABLE_VERSION + return TORCHAO_NIGHTLY_VERSION + + +def get_torch_url(): + torch_url = determine_torch_url(TORCH_URL_BASE) + if get_torchao_version(torch_url) == TORCHAO_STABLE_VERSION: + return "https://download.pytorch.org/whl/cpu" + return torch_url + + def cu134_requirements(torch_url, include_domains=False): if not torch_url.endswith("/cu134"): return [] @@ -79,19 +97,17 @@ def install_requirements(use_pytorch_nightly): sys.exit(1) # Determine the appropriate PyTorch URL based on CUDA delegate status - torch_url = determine_torch_url(TORCH_URL_BASE) + torch_url = get_torch_url() cu134_packages = cu134_requirements(torch_url) if cu134_packages: torch_url = determine_torch_url(TORCHAO_URL_BASE) if not use_pytorch_nightly: cu134_packages[0] = "torch" - # torchao's CUDA channel publishes x86_64 only, so asking for a CUDA build makes the pin - # unsatisfiable on aarch64. Only that case is special-cased: falling back everywhere would - # change which torchao a CPU x86_64 install resolves, and the CUDA build is genuinely wanted - # where it exists. This nightly is what a development checkout is tested against, and the - # wheel's own torchao lower bound is this same version so that installing the package - # afterwards leaves this pin in place rather than replacing it. - if platform.machine().lower() in ("aarch64", "arm64"): + torchao_version = get_torchao_version(torch_url) + if torchao_version == TORCHAO_STABLE_VERSION: + torchao_url = "https://download.pytorch.org/whl/cpu" + # CUDA TorchAO wheels cover x86_64 only; use CPU wheels on aarch64. + elif platform.machine().lower() in ("aarch64", "arm64"): # The cpu channel specifically, not the index root. The root carries every variant, and a # pin without a local segment admits all of them while ordering a local segment highest, # so the xpu channel's pure python wheel would win on version before pip compares wheel @@ -106,7 +122,7 @@ def install_requirements(use_pytorch_nightly): # that we don't need to set any version number there because they have already # been installed on CI before this step, so pip won't reinstall them ("torch==2.14.0" if use_pytorch_nightly else "torch"), - f"torchao=={TORCHAO_NIGHTLY_VERSION}", + f"torchao=={torchao_version}", ] # Install the requirements for core ExecuTorch package. @@ -191,7 +207,7 @@ def install_requirements(use_pytorch_nightly): def install_optional_example_requirements(use_pytorch_nightly): # Determine the appropriate PyTorch URL based on CUDA delegate status - torch_url = determine_torch_url(TORCH_URL_BASE) + torch_url = get_torch_url() cu134_packages = ( cu134_requirements(torch_url, include_domains=True) if use_pytorch_nightly diff --git a/setup.py b/setup.py index 4123acce86e..e273b39a1b2 100644 --- a/setup.py +++ b/setup.py @@ -1136,11 +1136,9 @@ def _torchao_requirement() -> str: sys.modules.setdefault("install_utils", install_utils) spec.loader.exec_module(module) - version = module.TORCHAO_NIGHTLY_VERSION - if ( - install_utils.determine_torch_url(module.TORCH_URL_BASE).endswith("/cu134") - and not module.torchao_from_source() - ): + torch_url = install_utils.determine_torch_url(module.TORCH_URL_BASE) + version = module.get_torchao_version(torch_url) + if torch_url.endswith("/cu134") and not module.torchao_from_source(): version = module.CU134_TORCHAO_NIGHTLY_VERSION major, minor = (int(part) for part in version.split(".")[:2]) return f"torchao>={version},<{major}.{minor + 1}" @@ -1172,9 +1170,8 @@ def _base_dependencies() -> List[str]: # The MLX backend needs it too, though indirectly: it registers a torchao operator that # only exists once torchao has been imported. # - # The lower bound is the nightly install_requirements.py pins, so that installing this - # package over a development checkout leaves that pin in place. A bound at the stable - # release instead would evict it, because a dev release sorts below its own final. + # Match the version selected by install_requirements.py so installing the + # package preserves the CPU release or the selected nightly. # # The upper bound is what makes naming a pre-release safe. A specifier that names one # accepts pre-releases for this requirement, so without the bound pip would resolve a From 728e13f0f0612d85b9e701bd9b4d23e7f44363eb Mon Sep 17 00:00:00 2001 From: Jacob Stevens Date: Thu, 5 Nov 2026 13:17:22 -0500 Subject: [PATCH 098/128] Prepack stored inputs of clones and clamps (#24436) Summary: ## Ulterior Motive Unblock Swin V2's stored query/key/value biases and attention logit scales. ## Rationale **What**: Add `aten.clone.default` and `aten.clamp.default` to the existing stored-input prepack list. **Why**: Clone expects a tensor rather than a TensorRef. Clamp also needs a bound input resource; leaving its stored input as a reference can leave the shader descriptor unwritten. ## Details - Reuse the existing explicit prepack path without changing either kernel. - Cover both operators in the prepack pass and numerical engine tests. - Preserve the existing mutation guards and all nonstored-input paths. Authored with AI using Metamate on agentcloud. Reviewed By: digantdesai Differential Revision: D129088169 Pull Request resolved: https://github.com/pytorch/executorch/pull/24436 --- .../runtime/vulkan/passes/InsertPrepack.cpp | 4 +- .../runtime/vulkan/test_insert_prepack.cpp | 13 +++ .../runtime/vulkan/test_vulkan_engine.cpp | 110 ++++++++++++++++++ 3 files changed, 126 insertions(+), 1 deletion(-) diff --git a/backends/native/runtime/vulkan/passes/InsertPrepack.cpp b/backends/native/runtime/vulkan/passes/InsertPrepack.cpp index 10ce15d3c8e..0262702c0ad 100644 --- a/backends/native/runtime/vulkan/passes/InsertPrepack.cpp +++ b/backends/native/runtime/vulkan/passes/InsertPrepack.cpp @@ -19,9 +19,11 @@ namespace ptn::vulkan { namespace { bool needs_prepack(const Node& node) { - constexpr std::array kOps{ + constexpr std::array kOps{ "torch.ops.aten._to_copy.default", "torch.ops.aten.add.Tensor", + "torch.ops.aten.clamp.default", + "torch.ops.aten.clone.default", "torch.ops.aten.index.Tensor", "torch.ops.aten.mul.Tensor", "torch.ops.aten.slice_copy.Tensor", diff --git a/backends/native/test/runtime/vulkan/test_insert_prepack.cpp b/backends/native/test/runtime/vulkan/test_insert_prepack.cpp index 8d92fafc886..4a6bfa526ff 100644 --- a/backends/native/test/runtime/vulkan/test_insert_prepack.cpp +++ b/backends/native/test/runtime/vulkan/test_insert_prepack.cpp @@ -107,6 +107,19 @@ TEST(InsertPrepackTest, PrepacksConstantToCopyInput) { method.graph.node(2).input_value_ids(), std::vector({0, 4})); } +TEST(InsertPrepackTest, PrepacksConstantCloneAndClampInputs) { + for (const char* target : + {"torch.ops.aten.clone.default", "torch.ops.aten.clamp.default"}) { + SCOPED_TRACE(target); + Method method = make_method(); + method.graph.node(2).target = target; + + EXPECT_EQ(insert_prepack_nodes(method), 1); + EXPECT_EQ( + method.graph.node(2).input_value_ids(), std::vector({0, 4})); + } +} + TEST(InsertPrepackTest, LeavesSelfPrepackingOpUntouched) { Method method = make_method(); method.graph.node(2).target = "torch.ops.aten.linear.default"; diff --git a/backends/native/test/runtime/vulkan/test_vulkan_engine.cpp b/backends/native/test/runtime/vulkan/test_vulkan_engine.cpp index 516ebf074ff..48dff5afe3a 100644 --- a/backends/native/test/runtime/vulkan/test_vulkan_engine.cpp +++ b/backends/native/test/runtime/vulkan/test_vulkan_engine.cpp @@ -1473,6 +1473,87 @@ std::vector make_stored_long_to_copy_program() { builder, graph, {fbs::CreateOutputSpecDirect(builder, "sum")}, constants); } +// sum = input + op(bias), where `bias` is a stored constant and op is clone, +// or with `clamp` a clamp to [-1, 50]. +std::vector make_stored_operand_program(bool clamp) { + flatbuffers::FlatBufferBuilder builder; + const auto meta = create_tensor_meta(builder, fbs::ScalarType::FLOAT, {4}); + const std::vector> tensor_values = { + fbs::CreateTensorValueDirect(builder, "input", meta), + fbs::CreateTensorValueDirect(builder, "bias", meta), + fbs::CreateTensorValueDirect(builder, "operand", meta), + fbs::CreateTensorValueDirect(builder, "sum", meta), + }; + std::vector> nodes; + for (const char* name : {"input", "bias"}) { + const std::vector> outputs = { + fbs::CreateOutputDirect(builder, name)}; + nodes.push_back(fbs::CreateNodeDirect( + builder, name, fbs::OpKind::PLACEHOLDER, "", nullptr, &outputs)); + } + const auto float_arg = [&](double value) { + return fbs::CreateArgument( + builder, + fbs::ArgumentValue::FloatArg, + fbs::CreateFloatArg(builder, value).Union()); + }; + std::vector> op_inputs = { + fbs::CreateNamedArgumentDirect( + builder, "self", create_tensor_arg(builder, "bias"))}; + if (clamp) { + op_inputs.push_back( + fbs::CreateNamedArgumentDirect(builder, "min", float_arg(-1.0))); + op_inputs.push_back( + fbs::CreateNamedArgumentDirect(builder, "max", float_arg(50.0))); + } else { + op_inputs.push_back(fbs::CreateNamedArgumentDirect( + builder, "memory_format", create_none_arg(builder))); + } + const std::vector> operand_outputs = { + fbs::CreateOutputDirect(builder, "operand")}; + nodes.push_back(fbs::CreateNodeDirect( + builder, + "operand", + fbs::OpKind::CALL_FUNCTION, + clamp ? "torch.ops.aten.clamp.default" : "torch.ops.aten.clone.default", + &op_inputs, + &operand_outputs)); + const std::vector> add_inputs = { + fbs::CreateNamedArgumentDirect( + builder, "self", create_tensor_arg(builder, "input")), + fbs::CreateNamedArgumentDirect( + builder, "other", create_tensor_arg(builder, "operand")), + fbs::CreateNamedArgumentDirect( + builder, "alpha", create_int_arg(builder, 1)), + }; + const std::vector> sum_outputs = { + fbs::CreateOutputDirect(builder, "sum")}; + nodes.push_back(fbs::CreateNodeDirect( + builder, + "sum", + fbs::OpKind::CALL_FUNCTION, + "torch.ops.aten.add.Tensor", + &add_inputs, + &sum_outputs)); + const std::vector> output_inputs = { + fbs::CreateNamedArgumentDirect( + builder, "", create_tensor_arg(builder, "sum"))}; + nodes.push_back(fbs::CreateNodeDirect( + builder, "output", fbs::OpKind::OUTPUT, "", &output_inputs)); + const auto graph = fbs::CreateGraph( + builder, + builder.CreateVector(nodes), + create_strings(builder, {"input"}), + create_strings(builder, {"sum"}), + builder.CreateVector(tensor_values)); + const std::vector> constants = { + fbs::CreateNamedTensorRefDirect( + builder, "bias", "bias", meta, fbs::InputKind::PARAMETER), + }; + return finish_program( + builder, graph, {fbs::CreateOutputSpecDirect(builder, "sum")}, constants); +} + constexpr int64_t kHfTokens = 4; constexpr int64_t kHfHeads = 4; constexpr int64_t kHfKvHeads = 2; @@ -2306,6 +2387,35 @@ TEST(VulkanEngineTest, ConvertsStoredLongConstant) { EXPECT_EQ(output, (std::vector{3.5f, -0.5f, 9.5f, 103.5f})); } +TEST(VulkanEngineTest, ClonesAndClampsStoredConstant) { + const std::string header = + R"({"bias":{"dtype":"F32","shape":[4],"data_offsets":[0,16]}})"; + const std::vector bias = {3.0f, -2.0f, 7.0f, 100.0f}; + const std::vector input = {0.5f, 1.5f, 2.5f, 3.5f}; + for (const bool clamp : {false, true}) { + SCOPED_TRACE(clamp ? "clamp" : "clone"); + std::vector tensors = testing::make_safetensors(header, 16); + std::memcpy( + tensors.data() + sizeof(uint64_t) + header.size(), + bias.data(), + bias.size() * sizeof(float)); + CompiledProgram compiled = + compile_program(make_stored_operand_program(clamp), std::move(tensors)); + compiled.executable->set_input( + 0, input.data(), input.size(), ScalarType::Float); + compiled.executable->execute(); + std::vector output(4); + compiled.executable->get_output( + 0, output.data(), output.size(), ScalarType::Float); + std::vector expected(4); + for (size_t i = 0; i < expected.size(); ++i) { + expected[i] = + input[i] + (clamp ? std::clamp(bias[i], -1.0f, 50.0f) : bias[i]); + } + EXPECT_EQ(output, expected); + } +} + TEST(VulkanEngineTest, RejectsBufferViewResizeThatDoesNotDivide) { CompiledProgram compiled = compile_program(make_dynamic_buffer_view_index_program()); From 18086e7d26335eeb59d7cfe0d2715fb592be2442 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Thu, 5 Nov 2026 11:17:19 -0800 Subject: [PATCH 099/128] Give the Windows unit test jobs 180 minutes instead of 120 (#24666) The Windows unit test jobs (`unittest` and `unittest-editable` in pull, `unittest-release` in trunk) are now close to their 120 minute limit. Some runs hit the limit and are cancelled before their tests finish. In late September these jobs took about 85 to 105 minutes. On main in early November, successful runs took about 100 to 119 minutes. Since November 3, 8 of the 55 finished `unittest-release` runs on main that I checked were cancelled at 120 to 121 minutes. In the latest one, pytest was at 99% and no test had failed. Trunk must be green for viable/strict to move. So each cancelled run also keeps viable/strict from moving to that commit. This change raises the Windows limit in `_unittest.yml` from 120 to 180 minutes. That leaves about 1 hour of room over the slowest passing runs. The Linux and macOS limits do not change. Tested: the workflow file still parses as YAML. --- .github/workflows/_unittest.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/_unittest.yml b/.github/workflows/_unittest.yml index 4d9fa3a1083..5fe483c94b8 100644 --- a/.github/workflows/_unittest.yml +++ b/.github/workflows/_unittest.yml @@ -70,7 +70,7 @@ jobs: uses: pytorch/test-infra/.github/workflows/windows_job.yml@main with: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} - timeout: 120 + timeout: 180 script: | git config --global http.sslBackend openssl git submodule update --init --recursive From 88aeb9690a62d2657ddf34c28b44e1b8385dcbca Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Thu, 5 Nov 2026 11:17:35 -0800 Subject: [PATCH 100/128] Fix addmm and mm lowering for scales, bias shapes and identity permutes (#24658) `torch.addmm(bias, x, w, beta=..., alpha=...)` computes `beta * bias + alpha * (x @ w)`, with any broadcastable bias. Some lowering steps miss this, so some programs give wrong values or fail. ## What is wrong today - **`beta` and `alpha` are dropped.** `AddmmToLinearTransform` turns `addmm(b, x, w.t(), beta=2.0)` into `linear(x, w, b)` without the scale. XNNPACK delegates a scaled `addmm` to its fully connected op, which has no scales. - **Some bias shapes break.** The transform makes a `linear` with any bias, but ExecuTorch's `linear` kernel takes only a 1-D bias. The LLM export then fails at run time. XNNPACK misreads any bias that is not 1 value per output channel. - **A `[0, 1]` permute counts as a transpose,** so `addmm` and `mm` give wrong values with a square weight and fail with any other. - **Dropping the view after a linear fails** for an `[M, N]` bias and for `x @ x.t()`, because the transform points the linear at the dequantized tensor. ## What this change does 1. The transform keeps an `addmm` unless its weight is transposed, its scales are 1 and its bias is 1-D. `addmm_can_be_linear` holds the rule. Only `t` or a `[1, 0]` permute counts as a transpose, also for `mm`. 2. Samsung's partitioner uses it, and skips one with a constant weight, because constant propagation folds the transpose first. These run on the CPU instead of giving wrong values or failing to lower. 3. The view is kept for a bias of more than 1 dim, and only the linear's input is changed. 4. XNNPACK keeps out a scaled `addmm`, and one whose bias is not 1 value per output channel. Vulkan runs the same transform, and applies the scales itself for float weights. A 2-D bias was already correct there, but now its weight is transposed each run. ## Testing - New tests cover scaled `addmm`, bias shapes, program-input biases, a decomposed `F.linear`, a standalone `addmm`, a `[0, 1]` permute, and the input as bias or weight, each failing without its fix. - The Vulkan test passes on SwiftShader and fails without the fix; CI does not run it. - After the LLM export's `ConvertToLinearPass`, an `addmm` with a non-1-D bias now runs. An `F.linear` with one still fails, and XNNPACK misreads it. - 5 static-shape models and 8 dynamic-shape programs match main byte for byte. Samsung was not run. --- backends/samsung/partition/enn_partitioner.py | 15 +- backends/transforms/addmm_mm_to_linear.py | 65 +++++-- .../test/test_addmm_mm_to_linear.py | 179 ++++++++++++++++- backends/vulkan/test/test_vulkan_delegate.py | 25 +++ .../xnnpack/partition/config/gemm_configs.py | 20 +- backends/xnnpack/test/ops/test_addmm.py | 180 ++++++++++++++++++ 6 files changed, 456 insertions(+), 28 deletions(-) create mode 100644 backends/xnnpack/test/ops/test_addmm.py diff --git a/backends/samsung/partition/enn_partitioner.py b/backends/samsung/partition/enn_partitioner.py index 6541f03aad9..cd74a2f7735 100644 --- a/backends/samsung/partition/enn_partitioner.py +++ b/backends/samsung/partition/enn_partitioner.py @@ -23,6 +23,7 @@ ) from executorch.backends.samsung.serialization.enn_graph_schema import EnnGraph from executorch.backends.samsung.utils.utils import get_compile_spec +from executorch.backends.transforms.addmm_mm_to_linear import addmm_can_be_linear from executorch.exir.backend.backend_details import CompileSpec from executorch.exir.backend.canonical_partitioners.pattern_op_partitioner import ( generate_partitions_from_list_of_nodes, @@ -40,7 +41,6 @@ SUPPORTED_OPS = [ # support because preprocess in backend - exir_ops.edge.aten.addmm.default, exir_ops.edge.aten.add.Scalar, exir_ops.edge.aten.sub.Scalar, exir_ops.edge.aten.mul.Scalar, @@ -81,6 +81,19 @@ def is_node_supported(self, _, node: torch.fx.Node) -> bool: ]: return False + if node.target == exir_ops.edge.aten.addmm.default: + # ENN has no addmm, so take only an addmm that preprocess removes. + # It folds an addmm of constants away, and AddmmToLinearTransform + # turns an addmm into a linear when addmm_can_be_linear allows it. + # ConstantPropPass runs before that transform and folds the + # transpose of a constant weight, so such an addmm stays an addmm. + if node in self.nodes_in_const_subgraph: + return True + return ( + addmm_can_be_linear(node) + and node.args[2] not in self.nodes_in_const_subgraph + ) + if node.target in SUPPORTED_OPS: return True diff --git a/backends/transforms/addmm_mm_to_linear.py b/backends/transforms/addmm_mm_to_linear.py index cb4b8e36001..fdae1176bae 100644 --- a/backends/transforms/addmm_mm_to_linear.py +++ b/backends/transforms/addmm_mm_to_linear.py @@ -60,10 +60,18 @@ def get_dqlinear_input(node: torch.fx.Node): def replace_linear_view_copy_input_output(graph: torch.fx.Graph) -> torch.fx.Graph: """ - Replaces pattern: x -> view_copy -> view_copy -> linear -> view_copy -> y - with - x -> linear -> y + Removes the view_copy after a linear when the linear already produces its + shape, so x -> linear -> view_copy -> y becomes x -> linear -> y. A linear + with other readers besides the view is left as is. + + When the linear reads a dequantize_per_tensor.tensor node through view_copy + nodes and has no bias of more than one dim, it reads that node instead if + that makes its output match the view, so + dq -> view_copy -> linear -> view_copy -> y becomes dq -> linear -> y. Linear nodes can handle input tensor with > 2 dimensions. + + A view goes only when the shapes are known to be equal. With dynamic shapes, + the linear's input and the view need symbolic shapes in meta["val"]. """ ops = exir_ops.edge for node in graph.nodes: @@ -80,6 +88,12 @@ def replace_linear_view_copy_input_output(graph: torch.fx.Graph) -> torch.fx.Gra output_channels = weight_node.meta["val"].shape[0] input_node = node.args[0] dqlinear_input = get_dqlinear_input(input_node) + bias_node = node.args[2] if len(node.args) > 2 else None + if bias_node is not None and len(bias_node.meta["val"].shape) > 1: + # A linear kept from export can have a bias of more than one dim + # that follows the rows of the flattened input, and then it would + # not broadcast to the dequantized shape. + dqlinear_input = None # Reading the dequantized input before its views changes the shape of the # linear output, so it is done only when the view after the linear goes. for new_input in (dqlinear_input, input_node): @@ -92,7 +106,7 @@ def replace_linear_view_copy_input_output(graph: torch.fx.Graph) -> torch.fx.Gra if statically_known_true( sym_eq(output_shape, list(view_copy.meta["val"].shape)) ): - node.replace_input_with(input_node, new_input) + node.update_arg(0, new_input) node.meta["val"] = view_copy.meta["val"] view_copy.replace_all_uses_with(node) break @@ -100,6 +114,31 @@ def replace_linear_view_copy_input_output(graph: torch.fx.Graph) -> torch.fx.Gra return graph +def is_transpose(node: torch.fx.Node) -> bool: + """ + Returns whether node transposes a 2-D tensor, so that a linear can take the + tensor before it as its weight. + """ + ops = exir_ops.edge + if node.target == ops.aten.permute_copy.default: + return [d % 2 for d in node.args[1]] == [1, 0] + return node.target == ops.aten.t_copy.default + + +def addmm_can_be_linear(node: torch.fx.Node) -> bool: + """ + Returns whether linear(x, w, b) can replace addmm(b, x, w.t()). The weight + must be transposed, linear cannot scale its bias or its product, and + ExecuTorch's linear kernel takes only a 1-D bias. + """ + return ( + is_transpose(node.args[2]) + and node.kwargs.get("beta", 1) == 1 + and node.kwargs.get("alpha", 1) == 1 + and len(node.args[0].meta["val"].shape) == 1 + ) + + def replace_addmm_mm_with_linear(graph: torch.fx.Graph) -> torch.fx.Graph: """ Replace calls to addmm/mm with linear node @@ -118,14 +157,9 @@ def replace_addmm_mm_with_linear(graph: torch.fx.Graph) -> torch.fx.Graph: ): with graph.inserting_after(node): if node.target == ops.aten.addmm.default: - weight_t_node = node.args[2] - if weight_t_node.target not in [ - ops.aten.t_copy.default, - ops.aten.permute_copy.default, - ]: - # Skip this node as it appears to be a standalone `addmm` + if not addmm_can_be_linear(node): continue - weight_node = weight_t_node.args[0] + weight_node = node.args[2].args[0] args = (node.args[1], weight_node, node.args[0]) linear_node = graph.create_node( "call_function", ops.aten.linear.default, args @@ -135,14 +169,9 @@ def replace_addmm_mm_with_linear(graph: torch.fx.Graph) -> torch.fx.Graph: args[0].meta["val"], args[1].meta["val"], args[2].meta["val"] ) else: - weight_t_node = node.args[1] - if weight_t_node.target not in [ - ops.aten.t_copy.default, - ops.aten.permute_copy.default, - ]: - # Skip this node as it appears to be a standalone `mm` + if not is_transpose(node.args[1]): continue - weight_node = weight_t_node.args[0] + weight_node = node.args[1].args[0] args = (node.args[0], weight_node) linear_node = graph.create_node( "call_function", ops.aten.linear.default, args diff --git a/backends/transforms/test/test_addmm_mm_to_linear.py b/backends/transforms/test/test_addmm_mm_to_linear.py index 2b3fbf4e4fa..aff094eee6f 100644 --- a/backends/transforms/test/test_addmm_mm_to_linear.py +++ b/backends/transforms/test/test_addmm_mm_to_linear.py @@ -8,9 +8,10 @@ import torch from executorch.backends.transforms.addmm_mm_to_linear import ( + addmm_can_be_linear, apply_addmm_mm_to_linear_transform, ) -from executorch.exir import to_edge +from executorch.exir import EdgeCompileConfig, to_edge from executorch.exir.dialects._ops import ops as exir_ops from torch.export import Dim @@ -55,6 +56,78 @@ def forward(self, xq: torch.Tensor) -> torch.Tensor: return self.linear(_dequantize(xq).view(-1, 16)).view(-1, 8) +class _DequantizeLinearReadTwice(torch.nn.Module): + def __init__(self) -> None: + super().__init__() + self.linear = torch.nn.Linear(16, 8) + + def forward(self, xq: torch.Tensor): + y = self.linear(_dequantize(xq).view(-1, 16)) + return y, y.view(xq.shape[0], xq.shape[1], 8) + + +class _DequantizeSelfProduct(torch.nn.Module): + def forward(self, xq: torch.Tensor) -> torch.Tensor: + flat = _dequantize(xq).view(-1, 16) + return (flat @ flat.t()).view(xq.shape[0], xq.shape[1], -1) + + +class _DequantizeLinearBias(torch.nn.Module): + def __init__(self, input_as_bias: bool) -> None: + super().__init__() + self.weight = torch.nn.Parameter(torch.randn(16, 16)) + self.bias = torch.nn.Parameter(torch.randn(6, 16)) + self.input_as_bias = input_as_bias + + def forward(self, xq: torch.Tensor) -> torch.Tensor: + flat = _dequantize(xq).view(-1, 16) + bias = flat if self.input_as_bias else self.bias + return torch.nn.functional.linear(flat, self.weight, bias).view( + xq.shape[0], xq.shape[1], 16 + ) + + +class _Addmm(torch.nn.Module): + def __init__( + self, beta: float = 1.0, alpha: float = 1.0, bias_shape: tuple = (8,) + ) -> None: + super().__init__() + self.weight = torch.nn.Parameter(torch.randn(8, 16)) + self.bias = torch.nn.Parameter(torch.randn(bias_shape)) + self.beta = beta + self.alpha = alpha + + def forward(self, x: torch.Tensor) -> torch.Tensor: + return torch.addmm( + self.bias, x, self.weight.t(), beta=self.beta, alpha=self.alpha + ) + + +class _StandaloneAddmm(torch.nn.Module): + def __init__(self) -> None: + super().__init__() + self.weight = torch.nn.Parameter(torch.randn(16, 8)) + self.bias = torch.nn.Parameter(torch.randn(8)) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + return torch.addmm(self.bias, x, self.weight) + + +class _PermutedWeightMatmul(torch.nn.Module): + def __init__(self, dims: tuple, use_bias: bool) -> None: + super().__init__() + self.weight = torch.nn.Parameter(torch.randn(16, 16)) + self.bias = torch.nn.Parameter(torch.randn(16)) + self.dims = dims + self.use_bias = use_bias + + def forward(self, x: torch.Tensor) -> torch.Tensor: + weight = ( + self.weight.t() if self.dims is None else self.weight.permute(*self.dims) + ) + return torch.addmm(self.bias, x, weight) if self.use_bias else x @ weight + + class _DequantizeLinearViewAndFlatInput(torch.nn.Module): def __init__(self) -> None: super().__init__() @@ -86,14 +159,15 @@ def forward(self, xq: torch.Tensor): _DEQUANTIZE = exir_ops.edge.quantized_decomposed.dequantize_per_tensor.tensor +_ADDMM = exir_ops.edge.aten.addmm.default _LINEAR = exir_ops.edge.aten.linear.default _VIEW = exir_ops.edge.aten.view_copy.default class TestAddmmMmToLinear(unittest.TestCase): - def _transform(self, model, example, dynamic_shapes=None): + def _transform(self, model, example, dynamic_shapes=None, compile_config=None): ep = torch.export.export(model, (example,), dynamic_shapes=dynamic_shapes) - program = to_edge(ep).exported_program() + program = to_edge(ep, compile_config=compile_config).exported_program() apply_addmm_mm_to_linear_transform(program.graph_module.graph) return program @@ -140,13 +214,15 @@ def test_view_to_another_shape_with_equal_bounds_is_kept(self): torch.testing.assert_close(program.module()(x), model(x)) def test_linear_reads_the_dequantized_input_only_when_its_view_goes(self): - # The first linear has no view after it, so it keeps reading the - # flattened input. The second has a view only to its own output shape, - # which goes, while the linear keeps reading the flattened input. The - # third reads the dequantized tensor once its view goes, while the other - # reader of the flattened input keeps its shape. + # The first linear has no view after it, and the second has another + # reader besides its view, so both keep reading the flattened input. The + # third has a view only to its own output shape, which goes, while the + # linear keeps reading the flattened input. The fourth reads the + # dequantized tensor once its view goes, while the other reader of the + # flattened input keeps its shape. for model, reads in ( (_DequantizeLinear(), _VIEW), + (_DequantizeLinearReadTwice(), _VIEW), (_DequantizeLinearNoOpView(), _VIEW), (_DequantizeLinearViewAndFlatInput(), _DEQUANTIZE), ): @@ -161,6 +237,93 @@ def test_linear_reads_the_dequantized_input_only_when_its_view_goes(self): ) torch.testing.assert_close(program.module()(x), model(x)) + def test_weight_that_is_also_the_input_is_kept(self): + # flat @ flat.t() becomes a linear whose input and weight are one node. + # Only the input is pointed at the dequantized tensor when the view goes. + model = _DequantizeSelfProduct() + x = _int8(2, 3, 16) + program = self._transform(model, x) + (linear,) = self._linears(program) + self.assertEqual(linear.args[0].target, _DEQUANTIZE) + self.assertEqual(linear.args[1].target, _VIEW) + self.assertIs(program.graph_module.graph.output_node().args[0][0], linear) + # Products of 2 dequantized inputs reach about 1e3, and the 3-D linear + # and the 2-D matmul round them differently. + torch.testing.assert_close(program.module()(x), model(x), rtol=1e-4, atol=1e-3) + + def test_bias_with_more_than_one_dim_keeps_the_input(self): + # A linear kept from export can add a bias per row of the flattened + # input, which does not broadcast to the dequantized shape, so the + # linear and its view stay. + config = EdgeCompileConfig(preserve_ops=[torch.ops.aten.linear.default]) + for input_as_bias in (False, True): + with self.subTest(input_as_bias=input_as_bias): + model = _DequantizeLinearBias(input_as_bias) + x = _int8(2, 3, 16) + program = self._transform(model, x, compile_config=config) + (linear,) = self._linears(program) + self.assertEqual(linear.args[0].target, _VIEW) + output = program.graph_module.graph.output_node().args[0][0] + self.assertEqual(output.target, _VIEW) + torch.testing.assert_close(program.module()(x), model(x)) + + def test_only_an_addmm_without_scales_becomes_a_linear(self): + # linear cannot scale its bias or its product, so only an addmm with + # beta and alpha of 1 becomes a linear. + for beta, alpha, linears in ((1.0, 1.0, 1), (2.0, 1.0, 0), (1.0, 0.5, 0)): + with self.subTest(beta=beta, alpha=alpha): + model = _Addmm(beta, alpha) + x = torch.randn(6, 16) + program = self._transform(model, x) + self.assertEqual(len(self._linears(program)), linears) + torch.testing.assert_close(program.module()(x), model(x)) + + def test_only_an_addmm_of_a_transposed_weight_can_be_linear(self): + # A standalone addmm(b, x, w) has no transpose for a linear to absorb. + for model, expected in ((_Addmm(), True), (_StandaloneAddmm(), False)): + with self.subTest(model=type(model).__name__): + ep = torch.export.export(model, (torch.randn(6, 16),)) + graph = to_edge(ep).exported_program().graph + (addmm,) = [n for n in graph.nodes if n.target == _ADDMM] + self.assertEqual(addmm_can_be_linear(addmm), expected) + + def test_only_an_addmm_with_a_1d_bias_becomes_a_linear(self): + # ExecuTorch's linear kernel takes only a 1-D bias, while addmm takes + # any bias that broadcasts to the output. + for bias_shape, linears in ( + ((8,), 1), + ((1,), 1), + ((1, 8), 0), + ((6, 8), 0), + ((6, 1), 0), + ((), 0), + ): + with self.subTest(bias_shape=bias_shape): + model = _Addmm(bias_shape=bias_shape) + x = torch.randn(6, 16) + program = self._transform(model, x) + self.assertEqual(len(self._linears(program)), linears) + torch.testing.assert_close(program.module()(x), model(x)) + + def test_only_a_transposed_weight_becomes_a_linear_weight(self): + # permute(0, 1) leaves the weight as it is, so it is not a transpose. + keep_t = EdgeCompileConfig( + _check_ir_validity=False, preserve_ops=[torch.ops.aten.t.default] + ) + for dims, config, linears in ( + ((0, 1), None, 0), + ((1, 0), None, 1), + ((-1, -2), None, 1), + (None, keep_t, 1), + ): + for use_bias in (True, False): + with self.subTest(dims=dims, use_bias=use_bias): + model = _PermutedWeightMatmul(dims, use_bias) + x = torch.randn(6, 16) + program = self._transform(model, x, compile_config=config) + self.assertEqual(len(self._linears(program)), linears) + torch.testing.assert_close(program.module()(x), model(x)) + def test_linears_sharing_a_dequantized_input_both_lose_their_views(self): model = _DequantizeTwoLinearViews() for name, dynamic_shapes in (("static", None), ("bounded", _BOUNDED)): diff --git a/backends/vulkan/test/test_vulkan_delegate.py b/backends/vulkan/test/test_vulkan_delegate.py index 27c698d2e55..d05c0ccb3d8 100644 --- a/backends/vulkan/test/test_vulkan_delegate.py +++ b/backends/vulkan/test/test_vulkan_delegate.py @@ -863,6 +863,31 @@ def forward(self, x): self.lower_module_and_test_output(module, sample_inputs) + def test_vulkan_backend_addmm(self): + class AddmmModule(torch.nn.Module): + def __init__(self, bias_shape, beta, alpha): + super().__init__() + self.weight = torch.nn.Parameter(torch.randn(8, 16)) + self.bias = torch.nn.Parameter(torch.randn(bias_shape)) + self.beta = beta + self.alpha = alpha + + def forward(self, x): + return torch.addmm( + self.bias, x, self.weight.t(), beta=self.beta, alpha=self.alpha + ) + + for bias_shape, beta, alpha in ( + ((8,), 1.0, 1.0), + ((8,), 2.0, 1.0), + ((8,), 1.0, 0.5), + ((4, 8), 1.0, 1.0), + ): + with self.subTest(bias_shape=bias_shape, beta=beta, alpha=alpha): + self.lower_module_and_test_output( + AddmmModule(bias_shape, beta, alpha), (torch.randn(4, 16),) + ) + def test_vulkan_backend_bmm(self): class BMMModule(torch.nn.Module): def __init__(self): diff --git a/backends/xnnpack/partition/config/gemm_configs.py b/backends/xnnpack/partition/config/gemm_configs.py index 018df9e3ddc..0c3eec7e4c8 100644 --- a/backends/xnnpack/partition/config/gemm_configs.py +++ b/backends/xnnpack/partition/config/gemm_configs.py @@ -39,7 +39,7 @@ from executorch.exir.backend.utils import WhyNoPartition from executorch.exir.dialects._ops import ops as exir_ops from torch.export import ExportedProgram -from torch.fx.experimental.symbolic_shapes import free_symbols +from torch.fx.experimental.symbolic_shapes import free_symbols, statically_known_true from torch.fx.passes.utils.source_matcher_utils import ( get_source_partitions, SourcePartition, @@ -484,6 +484,24 @@ def _get_weight_deps( return super()._get_weight_deps(node, ep, precision) + def check_constraints(self, node: torch.fx.Node, ep: ExportedProgram) -> bool: + # XNNPACK's fully connected op cannot scale the bias or the product. + if node.kwargs.get("beta", 1) != 1 or node.kwargs.get("alpha", 1) != 1: + why(node, "Addmm with beta or alpha other than 1 is not supported") + return False + # The fully connected op also takes 1 bias value per output channel. + # MMConfig inherits this check, and an mm has no bias argument. + if self.bias_idx is not None: + bias = get_input_node(node, self.bias_idx).meta["val"] + channels = node.meta["val"].shape[-1] + # A bias whose size is not known when lowering is kept out as well. + if not statically_known_true(bias.numel() == channels) or ( + bias.dim() > 0 and not statically_known_true(bias.shape[-1] == channels) + ): + why(node, "Addmm bias must have 1 value per output channel") + return False + return super().check_constraints(node, ep) + def get_deps( self, node: torch.fx.Node, diff --git a/backends/xnnpack/test/ops/test_addmm.py b/backends/xnnpack/test/ops/test_addmm.py new file mode 100644 index 00000000000..19de582ffbd --- /dev/null +++ b/backends/xnnpack/test/ops/test_addmm.py @@ -0,0 +1,180 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import unittest + +import torch +from executorch.backends.xnnpack.partition.xnnpack_partitioner import XnnpackPartitioner +from executorch.backends.xnnpack.test.tester import Tester, ToEdgeTransformAndLower + + +class TestAddmm(unittest.TestCase): + def setUp(self): + torch._dynamo.reset() + + class AddmmWithScales(torch.nn.Module): + def __init__(self): + super().__init__() + self.weight = torch.nn.Parameter(torch.randn(16, 8)) + self.bias = torch.nn.Parameter(torch.randn(8)) + + def forward(self, x): + return ( + # partitioned + torch.addmm(self.bias, x, self.weight), + # not partitioned, since beta or alpha is not 1 + torch.addmm(self.bias, x, self.weight, beta=2.0), + torch.addmm(self.bias, x, self.weight, alpha=0.5), + ) + + def test_fp32_addmm_with_scales(self): + ( + Tester(self.AddmmWithScales(), (torch.randn(6, 16),)) + .export() + .to_edge_transform_and_lower() + .check_count({"executorch_exir_dialects_edge__ops_aten_addmm_default": 2}) + .check_count({"torch.ops.higher_order.executorch_call_delegate": 1}) + .to_executorch() + .serialize() + .run_method_and_compare_outputs() + ) + + class AddmmWithBiases(torch.nn.Module): + def __init__(self): + super().__init__() + self.weight = torch.nn.Parameter(torch.randn(16, 8)) + self.channel_bias = torch.nn.Parameter(torch.randn(8)) + self.row_bias = torch.nn.Parameter(torch.randn(1, 8)) + self.full_bias = torch.nn.Parameter(torch.randn(8, 8)) + self.column_bias = torch.nn.Parameter(torch.randn(8, 1)) + self.scalar_bias = torch.nn.Parameter(torch.randn(1)) + self.one_channel_weight = torch.nn.Parameter(torch.randn(16, 1)) + self.one_channel_bias = torch.nn.Parameter(torch.randn(())) + + def forward(self, x): + return ( + # partitioned, since the bias has 1 value per output channel + torch.addmm(self.channel_bias, x, self.weight), + torch.addmm(self.row_bias, x, self.weight), + torch.addmm(self.one_channel_bias, x, self.one_channel_weight), + # not partitioned + torch.addmm(self.full_bias, x, self.weight), + torch.addmm(self.column_bias, x, self.weight), + torch.addmm(self.scalar_bias, x, self.weight), + ) + + def test_fp32_addmm_with_biases(self): + ( + Tester(self.AddmmWithBiases(), (torch.randn(8, 16),)) + .export() + .to_edge_transform_and_lower() + .check_count({"executorch_exir_dialects_edge__ops_aten_addmm_default": 3}) + .check_count({"torch.ops.higher_order.executorch_call_delegate": 1}) + .to_executorch() + .serialize() + .run_method_and_compare_outputs() + ) + + class LinearWithBiases(torch.nn.Module): + def __init__(self): + super().__init__() + self.weight = torch.nn.Parameter(torch.randn(8, 16)) + self.channel_bias = torch.nn.Parameter(torch.randn(8)) + self.row_bias = torch.nn.Parameter(torch.randn(1, 8)) + self.full_bias = torch.nn.Parameter(torch.randn(8, 8)) + self.column_bias = torch.nn.Parameter(torch.randn(8, 1)) + self.scalar_bias = torch.nn.Parameter(torch.randn(1)) + + def forward(self, x): + linear = torch.nn.functional.linear + return ( + # partitioned, since the bias has 1 value per output channel + linear(x, self.weight, self.channel_bias), + linear(x, self.weight, self.row_bias), + # not partitioned + linear(x, self.weight, self.full_bias), + linear(x, self.weight, self.column_bias), + linear(x, self.weight, self.scalar_bias), + ) + + def test_fp32_decomposed_linear_with_biases(self): + # to_edge decomposes F.linear into addmm(bias, x, weight.t()), which + # XNNPACK takes through the linear source partition. + ( + Tester(self.LinearWithBiases(), (torch.randn(8, 16),)) + .export() + .to_edge() + .partition() + .check_count({"executorch_exir_dialects_edge__ops_aten_addmm_default": 3}) + .to_executorch() + .serialize() + .run_method_and_compare_outputs() + ) + + class AddmmWithRuntimeBiases(torch.nn.Module): + def __init__(self): + super().__init__() + self.weight = torch.nn.Parameter(torch.randn(16, 8)) + + def forward(self, channel_bias, full_bias, x, n): + k = n.item() + torch._check(k >= 0) + torch._check(k <= x.shape[0]) + return ( + # partitioned, since the bias has 1 value per output channel + torch.addmm(channel_bias, x, self.weight), + # not partitioned, since the number of bias rows is dynamic + torch.addmm(full_bias, x, self.weight), + # not partitioned, and checking a bias whose size depends on the + # data must not fail the lowering + torch.addmm(full_bias.narrow(0, 0, k), x.narrow(0, 0, k), self.weight), + ) + + def test_fp32_addmm_with_runtime_biases(self): + # This flag lets a bias that is a program input into the delegate. + partitioner = XnnpackPartitioner(force_non_static_weights_for_f32_linear=True) + inputs = ( + torch.randn(8), + torch.randn(8, 8), + torch.randn(8, 16), + torch.tensor(3), + ) + rows = torch.export.Dim("rows", max=16) + ( + Tester( + self.AddmmWithRuntimeBiases(), + inputs, + dynamic_shapes=(None, {0: rows}, {0: rows}, None), + ) + .export() + .to_edge_transform_and_lower( + ToEdgeTransformAndLower(partitioners=[partitioner]) + ) + .check_count({"executorch_exir_dialects_edge__ops_aten_addmm_default": 2}) + .to_executorch() + .serialize() + .run_method_and_compare_outputs(inputs=inputs) + ) + + class MM(torch.nn.Module): + def __init__(self): + super().__init__() + self.weight = torch.nn.Parameter(torch.randn(16, 8)) + + def forward(self, x): + return torch.mm(x, self.weight) + + def test_fp32_mm(self): + ( + Tester(self.MM(), (torch.randn(8, 16),)) + .export() + .to_edge_transform_and_lower() + .check_not(["executorch_exir_dialects_edge__ops_aten_mm_default"]) + .check_count({"torch.ops.higher_order.executorch_call_delegate": 1}) + .to_executorch() + .serialize() + .run_method_and_compare_outputs() + ) From aeb0b1f678bba5476c1ce97898cc24672c12a7dc Mon Sep 17 00:00:00 2001 From: Paul Wilk Date: Thu, 5 Nov 2026 13:24:07 -0600 Subject: [PATCH 101/128] Arm backend: Quantize argmax input so float logits lower to TOSA ARGMAX (#24394) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Summary The Arm quantizer never annotated `aten.argmax`, so an argmax fed by a float tensor, or by a dequantized producer such as a quantized linear head, stayed in fp32 and the INT-profile partitioner rejected it with "TOSA ARGMAX requires PRO-FP for torch.float32 input". In a logits → argmax → embedding chain this left the argmax on the CPU between two NPU delegates. This change annotates only the argmax input (the TOSA ARGMAX output is int32 and carries no quantization) and lets the argmax support check look through a leading dequantize node, so it judges the dtype the folded graph will actually present. A `Linear(64, 4096)` → `argmax` → `Embedding(4096, 64)` model now lowers to a single Ethos-U85 command stream: `NPU_OP_CONV`, an `ARGMAX_X` pool over the 4096 logits, and a gather DMA, with zero CPU operators reported by Vela. Suggested label: `Release notes: arm`. ### Test plan Two tests are added to `backends/arm/test/ops/test_argmax.py`: float input on the TOSA INT pipeline (asserting exactly one `ARGMAX` op), and the same through the Ethos-U85 flow up to the Vela compile. ``` lintrunner backends/arm/quantizer/quantizer_support.py backends/arm/operator_support/tosa_supported_operators.py backends/arm/test/ops/test_argmax.py pytest -c backends/arm/test/pytest.ini backends/arm/test/ops/test_argmax.py ``` On an editable build of main at 05092c126d (torch 2.14.0+cpu, Vela 5.2.0): lint clean; 24 passed, 12 skipped (no model-converter for VGF), 3 xfailed (no Corstone-300 runner), 0 failed. The U85 subset was additionally run on the Corstone-320 FVP with the semihosting runner: 9 passed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) cc @digantdesai @freddan80 @per @zingo @oscarandersson8218 @mansnils @Sebastian-Larsson @robell @rascani @nil-is-all Signed-off-by: Paul Wilk Co-authored-by: Claude Fable 5.1 Co-authored-by: RJ Ascani --- .../tosa_supported_operators.py | 8 +++++- backends/arm/quantizer/quantizer_support.py | 3 ++ backends/arm/test/ops/test_argmax.py | 28 +++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/backends/arm/operator_support/tosa_supported_operators.py b/backends/arm/operator_support/tosa_supported_operators.py index ee4bf6dc24a..677f8b605d3 100644 --- a/backends/arm/operator_support/tosa_supported_operators.py +++ b/backends/arm/operator_support/tosa_supported_operators.py @@ -1357,7 +1357,13 @@ def _is_tosa_argmax_supported(self, node: torch.fx.Node) -> bool: input_node = typing.cast(torch.fx.Node, node.args[0]) input_tensor = get_first_fake_tensor(input_node) - if not self._is_tosa_argmax_dtype_supported(node, input_tensor.dtype): + input_dtype = input_tensor.dtype + if input_node.target in DQ_OPS: + # The quantizer annotated argmax's input: the dequantize is folded + # before lowering and TOSA ARGMAX sees the quantized dtype. + dq_input = typing.cast(torch.fx.Node, input_node.args[0]) + input_dtype = get_first_fake_tensor(dq_input).dtype + if not self._is_tosa_argmax_dtype_supported(node, input_dtype): return False input_rank = len(input_tensor.shape) diff --git a/backends/arm/quantizer/quantizer_support.py b/backends/arm/quantizer/quantizer_support.py index b178558c52c..ad8fad8ef22 100644 --- a/backends/arm/quantizer/quantizer_support.py +++ b/backends/arm/quantizer/quantizer_support.py @@ -252,6 +252,9 @@ def check_pattern(cls, pattern): (torch.ops.aten.ge.Scalar,), (torch.ops.aten.eq.Scalar,), (torch.ops.aten.ne.Scalar,), + # Integer-valued output: only the input is annotated, the TOSA ARGMAX + # output is int32. + (torch.ops.aten.argmax.default,), # TFA decomposes these recurrent ops after quantization support is # selected, so the original operators must be admitted here. (torch.ops.aten.lstm.input,), diff --git a/backends/arm/test/ops/test_argmax.py b/backends/arm/test/ops/test_argmax.py index 0a640a85ace..c8be3a57bdb 100644 --- a/backends/arm/test/ops/test_argmax.py +++ b/backends/arm/test/ops/test_argmax.py @@ -210,3 +210,31 @@ def test_argmax_vgf_quant(test_data: Tuple[input_t, int]): quantize=True, ) pipeline.run() + + +@common.parametrize("test_data", Argmax.test_data) +def test_argmax_tosa_INT_quantized(test_data: Tuple[input_t, int]): + """Float input: the quantizer annotates argmax's input, ARGMAX lowers.""" + data, dim = test_data() + pipeline = TosaPipelineINT[input_t]( + Argmax(dim), + data, + aten_op, + [exir_op, to_copy_exir_op], + ) + pipeline.count_tosa_ops({"ARGMAX": 1}) + pipeline.run() + + +@common.parametrize("test_data", Argmax.test_data) +def test_argmax_u85_INT_quantized(test_data: Tuple[input_t, int]): + """The same through the Ethos-U85 flow up to the Vela compile.""" + data, dim = test_data() + pipeline = EthosU85PipelineINT[input_t]( + Argmax(dim), + data, + aten_op, + [exir_op, to_copy_exir_op], + run_on_fvp=False, + ) + pipeline.run() From 8d34671e255413dc7bb568fbf830666371074178 Mon Sep 17 00:00:00 2001 From: Jacob Stevens Date: Thu, 5 Nov 2026 14:57:17 -0500 Subject: [PATCH 102/128] Fix integer divisor handling in portable remainder (#24348) ### Summary `remainder.Scalar_out` checked the scalar for zero as a `double` and then cast it to the compute dtype, so a scalar such as 256 for an int8 tensor became 0 and reached `a % 0` (SIGFPE on x86). The check now runs on the cast value, so like ATen, 256 raises a division-by-zero error while 255 and 257 wrap to -1 and 1. Empty inputs skip the check and return an empty tensor, also matching ATen. The integer `remainder_override` computed `INT_MIN % -1`, which overflows. Like ATen's `remainder_kernel`, a divisor of -1 now returns 0. These are the same issues #24170 fixes for `fmod` and `div`, found while reviewing that PR. ### Test plan Added `MostNegativeByMinusOneIsZero` (int32 and int64), `TensorIntegerDivisionByZeroFails`, `ScalarIntegerDivisionByZeroFails`, `ScalarIsCastToIntegerDtype`, `ScalarIsCastToUnsignedDtype`, and `EmptyInputWithZeroScalarDivisor` to `op_remainder_test.cpp`; expected values match eager PyTorch 2.14. Built `portable_kernels_test` with CMake (Release, `EXECUTORCH_BUILD_TESTS=ON`) on macOS arm64: all 1743 tests pass. With the kernel's scalar zero check removed, `ScalarIntegerDivisionByZeroFails`, `ScalarIsCastToIntegerDtype`, and `ScalarIsCastToUnsignedDtype` fail. `MostNegativeByMinusOneIsZero` passes on arm64 either way because `sdiv` does not trap; it guards the x86 crash. This PR was authored with AI assistance (Claude Code), with an additional review pass by Codex. --- kernels/portable/cpu/op_remainder.cpp | 26 +++++---- kernels/portable/cpu/util/math_util.h | 6 +++ kernels/test/op_remainder_test.cpp | 76 +++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 9 deletions(-) diff --git a/kernels/portable/cpu/op_remainder.cpp b/kernels/portable/cpu/op_remainder.cpp index 912827cf3a8..d65ca088531 100644 --- a/kernels/portable/cpu/op_remainder.cpp +++ b/kernels/portable/cpu/op_remainder.cpp @@ -104,15 +104,6 @@ Tensor& remainder_Scalar_out( InvalidArgument, out); - // Check for intergral division by zero - ET_KERNEL_CHECK_MSG( - ctx, - !(executorch::runtime::isIntegralType(common_type, true) && - utils::scalar_to(b) == 0), - InvalidArgument, - out, - "Remainder operation encountered integer division by zero"); - // Check Dim Order ET_KERNEL_CHECK( ctx, tensors_have_same_dim_order(a, out), InvalidArgument, out); @@ -127,8 +118,18 @@ Tensor& remainder_Scalar_out( // @lint-ignore CLANGTIDY facebook-hte-CArray ET_DEFINE_OPERATOR_NAME(op_name, "remainder.Scalar_out"); + bool div_by_zero_error = false; + ET_SWITCH_REAL_TYPES(compute_type, ctx, op_name, CTYPE_COMPUTE, [&]() { + // Like ATen, the scalar takes the compute dtype first, so a scalar such as + // 256 for an int8 tensor becomes 0. const CTYPE_COMPUTE val_b = utils::scalar_to(b); + if constexpr (std::is_integral_v) { + if (val_b == 0 && a.numel() > 0) { + div_by_zero_error = true; + return; + } + } utils::apply_unitensor_elementwise_fn< CTYPE_COMPUTE, op_name, @@ -143,6 +144,13 @@ Tensor& remainder_Scalar_out( out); }); + ET_KERNEL_CHECK_MSG( + ctx, + !div_by_zero_error, + InvalidArgument, + out, + "Remainder operation encountered integer division by zero"); + return out; } diff --git a/kernels/portable/cpu/util/math_util.h b/kernels/portable/cpu/util/math_util.h index 41ad2f82898..be29bb366f0 100644 --- a/kernels/portable/cpu/util/math_util.h +++ b/kernels/portable/cpu/util/math_util.h @@ -221,6 +221,12 @@ template < typename CTYPE, typename std::enable_if::value, int>::type = 0> CTYPE remainder_override(CTYPE a, CTYPE b) { + if constexpr (std::is_signed::value) { + // a % -1 overflows for the most negative value; the result is always 0. + if (b == -1) { + return 0; + } + } CTYPE rem = a % b; if constexpr (std::is_signed::value) { if (((a < 0) ^ (b < 0)) && rem != 0) { diff --git a/kernels/test/op_remainder_test.cpp b/kernels/test/op_remainder_test.cpp index 998743cdee5..765d84a9447 100644 --- a/kernels/test/op_remainder_test.cpp +++ b/kernels/test/op_remainder_test.cpp @@ -14,6 +14,8 @@ #include +#include + using namespace ::testing; using executorch::aten::Scalar; using executorch::aten::ScalarType; @@ -35,6 +37,22 @@ class OpRemainderOutTest : public OperatorTest { Tensor& out) { return torch::executor::aten::remainder_outf(context_, self, other, out); } + + template + void test_most_negative_by_minus_one() { + TensorFactory tf; + using CTYPE = typename TensorFactory::ctype; + + const CTYPE min = std::numeric_limits::min(); + Tensor self = tf.make({3}, {min, 7, -7}); + Tensor out = tf.full({3}, 1); + op_remainder_tensor_out(self, tf.full({3}, -1), out); + EXPECT_TENSOR_EQ(out, tf.zeros({3})); + + out = tf.full({3}, 1); + op_remainder_scalar_out(self, -1, out); + EXPECT_TENSOR_EQ(out, tf.zeros({3})); + } }; TEST_F(OpRemainderOutTest, SmokeTest) { @@ -75,3 +93,61 @@ TEST_F(OpRemainderOutTest, DoubleKeepsDoublePrecision) { EXPECT_TENSOR_CLOSE_WITH_TOL( out, tf.make({2}, {789.123456789, 210.876543211}), 0, 1e-6); } + +TEST_F(OpRemainderOutTest, MostNegativeByMinusOneIsZero) { + test_most_negative_by_minus_one(); + test_most_negative_by_minus_one(); +} + +TEST_F(OpRemainderOutTest, TensorIntegerDivisionByZeroFails) { + TensorFactory tf; + + Tensor self = tf.make({2}, {5, -3}); + Tensor out = tf.zeros({2}); + ET_EXPECT_KERNEL_FAILURE( + context_, op_remainder_tensor_out(self, tf.make({2}, {1, 0}), out)); +} + +TEST_F(OpRemainderOutTest, ScalarIntegerDivisionByZeroFails) { + TensorFactory tf; + + Tensor self = tf.make({2}, {5, -3}); + Tensor out = tf.zeros({2}); + ET_EXPECT_KERNEL_FAILURE(context_, op_remainder_scalar_out(self, 0, out)); +} + +TEST_F(OpRemainderOutTest, ScalarIsCastToIntegerDtype) { + TensorFactory tf; + + Tensor self = tf.make({3}, {5, -3, 100}); + Tensor out = tf.full({3}, 1); + // 255 and 257 become -1 and 1 as int8. + op_remainder_scalar_out(self, 255, out); + EXPECT_TENSOR_EQ(out, tf.zeros({3})); + out = tf.full({3}, 1); + op_remainder_scalar_out(self, 257, out); + EXPECT_TENSOR_EQ(out, tf.zeros({3})); + // 256 becomes 0 as int8. + ET_EXPECT_KERNEL_FAILURE(context_, op_remainder_scalar_out(self, 256, out)); +} + +TEST_F(OpRemainderOutTest, ScalarIsCastToUnsignedDtype) { + TensorFactory tf; + + Tensor self = tf.make({3}, {5, 255, 0}); + Tensor out = tf.full({3}, 1); + // -1 becomes 255 as uint8. + op_remainder_scalar_out(self, -1, out); + EXPECT_TENSOR_EQ(out, tf.make({3}, {5, 0, 0})); + // 256 becomes 0 as uint8. + ET_EXPECT_KERNEL_FAILURE(context_, op_remainder_scalar_out(self, 256, out)); +} + +TEST_F(OpRemainderOutTest, EmptyInputWithZeroScalarDivisor) { + TensorFactory tf; + + Tensor self = tf.make({0}, {}); + Tensor out = tf.make({0}, {}); + op_remainder_scalar_out(self, 0, out); + EXPECT_TENSOR_EQ(out, tf.make({0}, {})); +} From cc9acaaf42fc505e707ca3b78de9b280ac9fddc4 Mon Sep 17 00:00:00 2001 From: pssrawat <34485295+pssrawat@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:03:57 -0500 Subject: [PATCH 103/128] Emit Conv1d output layout copies after their squeeze (#24256) Summary: On the explicit-layout pass list, a Conv1d is lowered as a Conv2d with a unit height. `CortexMReplaceOpsWithChannelsLastVariants` wrapped each convolution output in a rank-4 NHWC-to-NCHW layout copy, followed by the squeeze that `ConvertConv1dToConv2dPass` adds to get back to rank 3. `RemovePermutesAroundElementwiseOps` cannot match a rank-3 region against a rank-4 copy behind a squeeze, so in residual or chained Conv1d blocks the layout copies between convolutions survived as transposes. `CortexMReplaceOpsWithChannelsLastVariants` now emits such an output as a squeeze followed by a rank-3 layout copy (`[0, 2, 1]`), reusing the existing squeeze node. It does this only when the convolution's sole user is a `squeeze_copy.dim(conv, 2)` with a rank-3 result; every other output keeps the rank-4 copy. One side effect: when user code transposes a Conv1d output around elementwise ops, the layout copy and the user's `transpose(1, 2)` are now directly adjacent. `RemovePermutesAroundElementwiseOps` cannot merge adjacent permutes and cancels the user's two transposes against each other instead, so both layout copies survive: Conv1d -> transpose -> relu -> transpose -> Conv1d gets four transposes instead of two. The next change fixes this by running `FuseCascadedTransposeOrPermuteOps` before `RemovePermutesAroundElementwiseOps`. A Conv1d -> tanh -> Conv1d -> add -> relu -> Conv1d chain now lowers with only the two boundary transposes instead of six. This makes more regions reachable for `RemovePermutesAroundElementwiseOps`, including regions with lower-rank constants, such as a `[C, L]` or `[L]` buffer added to two Conv1d outputs. The pass's compensation for those constants is fixed separately in the shared pass, and this change depends on that fix. Authored with an AI coding assistant (Claude). Differential Revision: D128001279 Pull Request resolved: https://github.com/pytorch/executorch/pull/24256 --- .../cortex_m/passes/explicit_layout_pass.py | 39 ++++++++++- .../test/test_explicit_layout_pipeline.py | 65 +++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/backends/cortex_m/passes/explicit_layout_pass.py b/backends/cortex_m/passes/explicit_layout_pass.py index 659b67d729e..a4499f0e141 100644 --- a/backends/cortex_m/passes/explicit_layout_pass.py +++ b/backends/cortex_m/passes/explicit_layout_pass.py @@ -5,6 +5,8 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. +from typing import cast + import executorch.backends.transforms.channels_last_ops # noqa: F401 import torch @@ -26,7 +28,7 @@ from executorch.exir import ExportedProgram from executorch.exir.dialects._ops import ops as exir_ops from executorch.exir.pass_base import ExportPass, PassResult -from torch.fx import GraphModule, Node +from torch.fx import Graph, GraphModule, Node from torch.fx.node import Target @@ -74,6 +76,17 @@ def _supports_max_pool2d(node: Node) -> bool: ) +def _is_nchw_to_ncw_squeeze(node: Node) -> bool: + # The squeeze ConvertConv1dToConv2dPass emits: squeeze_copy.dim(conv, 2). + return ( + node.target == exir_ops.edge.aten.squeeze_copy.dim + # Squeezes NCHW H; % 4 normalizes a negative dim of the rank-4 input. + and cast(int, node.args[1]) % 4 == 2 + # Squeezing a non-unit dim is a no-op that keeps rank 4. + and node.meta["val"].dim() == 3 + ) + + _EXPLICIT_LAYOUT_OP_MAP: dict[Target, ChannelsLastOpSpec] = { exir_ops.edge.aten.convolution.default: ChannelsLastOpSpec( target=exir_ops.edge.channels_last.convolution.default, @@ -100,6 +113,30 @@ class CortexMReplaceOpsWithChannelsLastVariants(ReplaceOpsWithChannelsLastVarian def __init__(self, exported_program: ExportedProgram) -> None: super().__init__(exported_program, op_map=dict(_EXPLICIT_LAYOUT_OP_MAP)) + # Emit a Conv1d-as-Conv2d output as squeeze -> rank-3 layout copy rather + # than rank-4 copy -> squeeze, so RemovePermutesAroundElementwiseOps can + # cancel the copy against the elementwise ops that consume it. + @staticmethod + def _permute_node_output( + graph: Graph, + node_output: Node, + original_node_output: Node, + implicit_batch: bool, + ) -> None: + users = list(original_node_output.users) + if len(users) != 1 or not _is_nchw_to_ncw_squeeze(users[0]): + ReplaceOpsWithChannelsLastVariants._permute_node_output( + graph, node_output, original_node_output, implicit_batch + ) + return + squeeze = users[0] + squeeze.args = (node_output, 1) + with graph.inserting_after(squeeze): + permute = graph.call_function(LAYOUT_PERMUTE_COPY, (squeeze, [0, 2, 1])) + squeeze.replace_all_uses_with( + permute, delete_user_cb=lambda n: n is not permute + ) + class CortexMCanonicalizeViewCopyPermutePass(CanonicalizeViewCopyPermutePass): def __init__(self) -> None: diff --git a/backends/cortex_m/test/test_explicit_layout_pipeline.py b/backends/cortex_m/test/test_explicit_layout_pipeline.py index d29248e5647..683804fecfb 100644 --- a/backends/cortex_m/test/test_explicit_layout_pipeline.py +++ b/backends/cortex_m/test/test_explicit_layout_pipeline.py @@ -13,6 +13,9 @@ CortexMPassManager, LiftConstantTensorsPass, ) +from executorch.backends.cortex_m.passes.explicit_layout_pass import ( + _is_nchw_to_ncw_squeeze, +) from executorch.backends.cortex_m.quantizer.quantizer import CortexMQuantizer from executorch.backends.cortex_m.target_config import CortexM, CortexMTargetConfig from executorch.backends.cortex_m.test.tester import ( @@ -88,6 +91,18 @@ def forward(self, x): return self.conv2(self.conv1(torch.tanh(x)).view(1, 4, 32)) +class Conv1dChain(torch.nn.Module): + def __init__(self) -> None: + super().__init__() + self.conv1 = torch.nn.Conv1d(8, 8, 3, padding=1) + self.conv2 = torch.nn.Conv1d(8, 8, 3, padding=1) + self.conv3 = torch.nn.Conv1d(8, 8, 1) + + def forward(self, x): + y = torch.tanh(self.conv1(x)) + return self.conv3(torch.relu(self.conv2(y) + y)) + + class Apply(torch.nn.Module): def __init__(self, fn) -> None: super().__init__() @@ -669,3 +684,53 @@ def test_unquantized_constant_is_not_precomputed(use_explicit_layout): assert _count(program, exir_ops.edge.aten.full.default) == 1 tester.run_method_and_compare_outputs(inputs=inputs) + + +def test_conv1d_chain_keeps_only_the_boundary_layout_copies(): + torch.manual_seed(0) + inputs = (torch.randn(1, 8, 16),) + tester = _run_explicit_layout_passes(CortexMTester(Conv1dChain().eval(), inputs)) + program = tester.get_artifact(StageType.RUN_PASSES).exported_program() + + assert _count(program, exir_ops.edge.cortex_m.quantized_conv2d_nhwc.default) == 3 + assert [ + node.args[1] + for node in program.graph.find_nodes( + op="call_function", target=exir_ops.edge.cortex_m.transpose.default + ) + ] == [[0, 2, 1], [0, 2, 1]] + assert _count(program, exir_ops.edge.aten.permute_copy.default) == 0 + tester.run_method_and_compare_outputs(inputs=inputs, qtol=1) + + +def test_conv1d_output_is_squeezed_before_its_layout_copy(): + torch.manual_seed(0) + inputs = (torch.randn(1, 8, 16),) + tester = _run_explicit_layout_passes( + CortexMTester(torch.nn.Conv1d(8, 4, 3).eval(), inputs) + ) + program = tester.get_artifact(StageType.RUN_PASSES).exported_program() + + (conv,) = program.graph.find_nodes( + op="call_function", target=exir_ops.edge.cortex_m.quantized_conv2d_nhwc.default + ) + (squeeze,) = conv.users + (layout_copy,) = squeeze.users + assert squeeze.target == exir_ops.edge.aten.view_copy.default + assert squeeze.meta["val"].shape == (1, 14, 4) + assert layout_copy.target == exir_ops.edge.cortex_m.transpose.default + assert layout_copy.args[1] == [0, 2, 1] + tester.run_method_and_compare_outputs(inputs=inputs, qtol=1) + + +@pytest.mark.parametrize( + "dim,expected", [(2, True), (-2, True), (1, False), (-1, False)] +) +def test_only_a_height_squeeze_is_moved_above_the_layout_copy(dim, expected): + graph = torch.fx.Graph() + squeeze = graph.call_function( + exir_ops.edge.aten.squeeze_copy.dim, (graph.placeholder("x"), dim) + ) + squeeze.meta["val"] = torch.empty(1, 8, 16) + + assert _is_nchw_to_ncw_squeeze(squeeze) is expected From 348821cec6e5f296d7551ce4cc8d455a90232974 Mon Sep 17 00:00:00 2001 From: Mergen Nachin Date: Thu, 5 Nov 2026 15:07:52 -0500 Subject: [PATCH 104/128] Fix OpenRouter request and streaming compatibility in LLM serving (#24614) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenRouter clients that stop reading at the finish chunk lose usage sent in a later chunk. Context-overflow recovery also needs actionable token counts, including when only the worker can count the prompt. Add `--api-dialect openrouter` to the generic and Muse-Glimmer entrypoints for finish-chunk usage and compatible overflow errors. `/v1/models` reports the configured context limit so clients can set their compaction budget. Carry a dedicated `context_length_exceeded` error with prompt, context, and completion counts from native preparation through the multiplexed protocol into HTTP/SSE. Muse counts decoder positions, including BOS, image rows, and trailing text after earlier segments overflow. Image prompts bypass Python token-count preflight so a text-only lower bound cannot reach clients as an exact count. OpenRouter receives the same recovery metadata for Python and native overflow; default OpenAI prompt-only wording is preserved. Older workers without structured counts remain supported but cannot supply recovery metadata. An invalid default generation budget with an unknown context limit remains `InvalidArgument` when space is available, rather than suggesting that compaction will help. Normalize omitted, null, and empty historical tool arguments before rendering, and normalize empty Hermes-generated arguments without losing other calls. Preserve null parameter schemas and structured validation errors. Compacted histories may retain results after their calls were removed, repeat results, or reuse a call ID across turns. Validate messages without requiring a complete call/result log and preserve their content. IDs remain unique within one assistant message; arguments must be JSON objects and roles must be one of the supported five. Streaming readiness waits indefinitely by default; a finite timeout bounds generation readiness, including prefill, but not earlier preparation or session-transaction waiting. Late failures use structured SSE errors, and idle HTTP streams receive keepalive comments. A single producer task now owns iteration and cleanup, with a one-chunk queue for bounded read-ahead. Available chunks bypass heartbeat timers; disconnects cancel and join the producer before releasing the response. This preserves the existing preflight/header contract. The LLM-server CI job runs non-native Python tests before building C++. Tests depending on a native-worker fixture are marked automatically and run in a separate required pass after both executables are built, exported, and checked with `test -x`. The two passes write separate JUnit files and do not duplicate tests. Native overflow and session/client tests share an active-peer fixture rather than importing another test module. The opt-in CTest Python entry includes the overflow module too. Schema-validation failures in both dialects intentionally use the common HTTP 422 error envelope, replacing FastAPI's detail list and reporting the first validation error. This is separate from preserving the default OpenAI context-overflow message. Session recovery and Muse-specific reasoning/tool semantics are implemented in the dependent PRs. Validation: the image/task-ownership regressions reproduced 11 Python failures before the fixes, and a native regression reproduced false context-overflow errors for both zero and negative defaults. The revised #24614 workflow passes 1,082 Python tests: 1,032 before the build and all 50 native-backed cases afterward, including all six overflow round trips. Three unrelated Qwen-tokenizer tests skip; Qwen/Gemma tests are excluded from the local Muse pass. Removing either worker executable fails the native phase before pytest. All ten C++ suites and the CTest Python entry pass. The restacked suite passes 1,604 Python tests, including all 64 native-backed cases, with the same three tokenizer skips. Applicable lint passes. The heartbeat microbenchmark consumed 10,000 immediately available chunks over five measured runs: source iteration used one task rather than 10,000, and median wrapper time fell from 0.478 to 0.322 seconds. This measures the Python wrapper, not model throughput. Tests cover idle heartbeats, source errors/cancellation, bounded read-ahead, cleanup, and disconnect/session-lease release. Live validation passed 32 functional HTTP cases across OpenAI/OpenRouter and streaming/non-streaming, plus two disconnect-and-reuse scenarios. Four cases recovered from overflow by compacting and retrying the same session. Image-expanded counts are covered by native Muse preparation tests and eight HTTP forwarding regressions. Live vision inference is not claimed because the available artifact is text-only. Live functional validation uses off-graph Q4_K_XL/BF16 MLX, eight sessions, ATEM, eight prefix-cache entries, eviction enabled, DFlash disabled, Xcode 27A9275 and the existing Metal JIT cache, on an isolated port. This is not the Q4_K_M+DFlash performance comparison. Stack / merge order: #24614 → #24615 → #24616 → #24683. Authored with AI assistance using OpenAI Codex. --- .github/workflows/_llm_server.yml | 9 +- examples/llm_server/cpp/CMakeLists.txt | 1 + .../llm_server/cpp/multiplexed_worker.cpp | 23 +- examples/llm_server/python/chat_template.py | 34 +- examples/llm_server/python/errors.py | 35 +- .../python/multiplexed_worker_client.py | 18 +- examples/llm_server/python/protocol.py | 68 +- examples/llm_server/python/serve.py | 26 + examples/llm_server/python/server.py | 93 ++- examples/llm_server/python/serving_chat.py | 78 ++- examples/llm_server/python/session_runtime.py | 8 +- examples/llm_server/python/tests/conftest.py | 94 ++- .../test_multiplexed_worker_client_native.py | 74 +- .../python/tests/test_openrouter_recovery.py | 110 +++ .../python/tests/test_serve_launcher.py | 17 + .../python/tests/test_serving_regressions.py | 645 ++++++++++++++++++ .../llm_server/python/tests/test_sessions.py | 2 +- .../tests/test_worker_context_overflow.py | 123 ++++ .../llm_server/python/tool_parsers/hermes.py | 4 + examples/llm_server/python/worker_client.py | 5 +- examples/llm_server/spec/README.md | 79 ++- .../models/muse-glimmer/BATCHING_SERVING.md | 33 + .../runtime/prompt_preparation.cpp | 57 +- examples/models/muse-glimmer/serving/serve.py | 14 +- .../muse-glimmer/serving/serve_batching.py | 23 +- .../tests/batching_runtime_test.cpp | 18 +- .../models/muse-glimmer/tests/test_serve.py | 70 +- .../muse-glimmer/tests/test_serve_batching.py | 28 +- .../llm/serving/detail/prompt_preparer.h | 29 +- extension/llm/serving/serving_runtime.cpp | 12 +- .../llm/serving/test/prompt_preparer_test.cpp | 42 +- .../llm/serving/test/text_generation_test.cpp | 39 +- extension/llm/serving/types.h | 18 + 33 files changed, 1727 insertions(+), 202 deletions(-) create mode 100644 examples/llm_server/python/tests/test_openrouter_recovery.py create mode 100644 examples/llm_server/python/tests/test_serving_regressions.py create mode 100644 examples/llm_server/python/tests/test_worker_context_overflow.py diff --git a/.github/workflows/_llm_server.yml b/.github/workflows/_llm_server.yml index 4597c488001..05da4715a47 100644 --- a/.github/workflows/_llm_server.yml +++ b/.github/workflows/_llm_server.yml @@ -42,7 +42,8 @@ jobs: export PYTHONPATH="$(dirname "${PWD}"):${PYTHONPATH:-}" export PYTHONDONTWRITEBYTECODE=1 - python -m pytest -q \ + python -m pytest -q -ra -m 'not native_worker' \ + --junitxml="${RUNNER_TEST_RESULTS_DIR}/llm-server.xml" \ examples/llm_server/python/tests \ examples/models/muse-glimmer/tests/test_serve.py \ examples/models/muse-glimmer/tests/test_serve_batching.py \ @@ -62,6 +63,10 @@ jobs: -j "$(nproc)" ctest --test-dir cmake-out/examples/llm_server/cpp --output-on-failure + export EXECUTORCH_TEST_MULTIPLEXED_WORKER="${PWD}/cmake-out/examples/llm_server/cpp/test_multiplexed_worker" export EXECUTORCH_BATCHING_TEST_WORKER="${PWD}/cmake-out/examples/llm_server/cpp/test_batching_worker" + test -x "${EXECUTORCH_TEST_MULTIPLEXED_WORKER}" test -x "${EXECUTORCH_BATCHING_TEST_WORKER}" - python -m pytest -q examples/llm_server/python/tests/test_batching_end_to_end.py + python -m pytest -q -ra -m native_worker \ + --junitxml="${RUNNER_TEST_RESULTS_DIR}/llm-server-native.xml" \ + examples/llm_server/python/tests diff --git a/examples/llm_server/cpp/CMakeLists.txt b/examples/llm_server/cpp/CMakeLists.txt index 9db046ea08a..f294be53512 100644 --- a/examples/llm_server/cpp/CMakeLists.txt +++ b/examples/llm_server/cpp/CMakeLists.txt @@ -111,6 +111,7 @@ if(EXECUTORCH_LLM_SERVER_PYTHON_TESTS) "EXECUTORCH_TEST_MULTIPLEXED_WORKER=${_native_worker}" "PYTHONPATH=${EXECUTORCH_ROOT}/.." ${Python3_EXECUTABLE} -m pytest -q ${_python_tests}/test_multiplexed_worker_client_native.py + ${_python_tests}/test_worker_context_overflow.py ) set_tests_properties(multiplexed_worker_client_native PROPERTIES TIMEOUT 120) endif() diff --git a/examples/llm_server/cpp/multiplexed_worker.cpp b/examples/llm_server/cpp/multiplexed_worker.cpp index 4418a52a52c..ba3e4358940 100644 --- a/examples/llm_server/cpp/multiplexed_worker.cpp +++ b/examples/llm_server/cpp/multiplexed_worker.cpp @@ -49,6 +49,8 @@ const char* error_code(serving::ErrorCode code) { return "capacity_exhausted"; case serving::ErrorCode::Internal: return "internal"; + case serving::ErrorCode::ContextLengthExceeded: + return "context_length_exceeded"; } return "internal"; } @@ -57,6 +59,18 @@ Json failure(const char* code, const std::string& message) { return {{"error", message}, {"code", code}}; } +Json failure(const serving::ServingError& error) { + auto out = failure(error_code(error.code), error.message); + if (error.context_overflow) { + const auto& counts = *error.context_overflow; + out["context_overflow"] = { + {"prompt_tokens", counts.prompt_tokens}, + {"context_length", counts.context_length}, + {"completion_tokens", counts.completion_tokens}}; + } + return out; +} + Id unsigned_integer(const Json& value, bool positive = false) { if (!(value.is_number_unsigned() || (value.is_number_integer() && value.get() >= 0))) { @@ -199,7 +213,7 @@ serving::GenerationOptions generation_options(const Json& request) { Json terminal_json(const serving::TerminalEvent& event) { if (event.error) - return failure(error_code(event.error->code), event.error->message); + return failure(*event.error); if (event.finish_reason == serving::FinishReason::Failed) { return failure("internal", "generation failed"); } @@ -476,7 +490,7 @@ class Worker { { std::lock_guard lock(mutex_); if (const auto* error = std::get_if(&result)) { - complete(op, failure(error_code(error->code), error->message)); + complete(op, failure(*error)); } else { op->handle = std::get(std::move(result)); if (op->cancelled) @@ -516,10 +530,7 @@ class Worker { std::lock_guard lock(mutex_); if (failed_) return; - complete( - active, - error ? failure(error_code(error->code), error->message) - : Json{{ack, true}}); + complete(active, error ? failure(*error) : Json{{ack, true}}); } checkpoint(Checkpoint::TerminalEnqueued, active->id); } diff --git a/examples/llm_server/python/chat_template.py b/examples/llm_server/python/chat_template.py index 8a36ef1c99b..ca9885583b6 100644 --- a/examples/llm_server/python/chat_template.py +++ b/examples/llm_server/python/chat_template.py @@ -18,6 +18,7 @@ import logging from typing import Any, Optional +from .errors import APIError from .protocol import ChatMessage logger = logging.getLogger(__name__) @@ -73,8 +74,9 @@ def _decode_tool_call_arguments(messages: list[dict[str, Any]]) -> None: OpenAI sends assistant tool-call arguments as a JSON-encoded string, but HF chat templates expect a mapping (e.g. Qwen renders ``arguments|items`` into ```` tags). Without this, a multi-turn tool conversation makes - the template raise "Can only get item pairs from a mapping". Left as-is if - the value isn't valid JSON, so a template that wants the raw string still works. + the template raise "Can only get item pairs from a mapping". HTTP request + validation requires object-valued JSON. Direct template callers bypassing + request validation retain their original value if JSON decoding fails. """ for m in messages: for tc in m.get("tool_calls") or []: @@ -82,7 +84,9 @@ def _decode_tool_call_arguments(messages: list[dict[str, Any]]) -> None: if not isinstance(fn, dict): continue args = fn.get("arguments") - if isinstance(args, str): + if args is None or args == "": + fn["arguments"] = {} + elif isinstance(args, str): try: fn["arguments"] = json.loads(args) except (ValueError, TypeError): @@ -164,13 +168,23 @@ def render( if self._hf is not None: dumped = [m.model_dump(exclude_none=True) for m in messages] _decode_tool_call_arguments(dumped) - rendered = self._hf.apply_chat_template( - dumped, - tools=tools, - add_generation_prompt=True, - tokenize=False, - **kwargs, - ) + from jinja2 import TemplateError + + try: + rendered = self._hf.apply_chat_template( + dumped, + tools=tools, + add_generation_prompt=True, + tokenize=False, + **kwargs, + ) + except TemplateError as error: + raise APIError( + 400, + f"Invalid chat template input: {error}", + "invalid_request_error", + "invalid_value", + ) from error if self._strip_rendered_prefix and rendered.startswith( self._strip_rendered_prefix ): diff --git a/examples/llm_server/python/errors.py b/examples/llm_server/python/errors.py index f5d4a509840..016d64e7b55 100644 --- a/examples/llm_server/python/errors.py +++ b/examples/llm_server/python/errors.py @@ -23,7 +23,7 @@ def __init__( self.err_type = err_type self.code = code - def body(self) -> dict: + def body(self, *, openrouter: bool = False) -> dict: return { "error": {"message": self.message, "type": self.err_type, "code": self.code} } @@ -31,10 +31,13 @@ def body(self) -> dict: class ContextLengthExceeded(APIError): def __init__(self, num_tokens: int, max_context: int, completion_tokens: int = 0): - # completion_tokens > 0: the prompt fits but prompt + requested - # max_tokens would run past the window — reject up front rather than + self.num_tokens = num_tokens + self.max_context = max_context + self.completion_tokens = max(0, completion_tokens) + # completion_tokens > 0: prompt + requested max_tokens would run + # past the window — reject up front rather than # fail (or truncate) mid-generation. - if completion_tokens > 0: + if completion_tokens > 0 and num_tokens < max_context: message = ( f"This model's maximum context length is {max_context} tokens. " f"However, you requested {num_tokens + completion_tokens} tokens " @@ -54,6 +57,30 @@ def __init__(self, num_tokens: int, max_context: int, completion_tokens: int = 0 code="context_length_exceeded", ) + def body(self, *, openrouter: bool = False) -> dict: + body = super().body() + if openrouter: + # Muse's OpenRouter recovery consumes the message's prompt budget. + # Reserve output space without counting it as input to be compacted. + reserved = max(1, self.completion_tokens) + budget = self.max_context - reserved + body["error"]["metadata"] = { + "error_type": self.code, + "provider_code": self.code, + "context_length": self.max_context, + "prompt_tokens": self.num_tokens, + "completion_tokens": reserved, + } + if budget > 0: + body["error"]["message"] = ( + f"This model's maximum context length is {budget} tokens. " + f"However, your messages resulted in {self.num_tokens} tokens " + f"(the prompt budget reserves {reserved} completion tokens " + f"from the {self.max_context}-token model window). " + "Please reduce the length of the messages or completion." + ) + return body + class GenerationError(APIError): def __init__(self, detail: str): diff --git a/examples/llm_server/python/multiplexed_worker_client.py b/examples/llm_server/python/multiplexed_worker_client.py index 768a6fee86c..f839c306cf6 100644 --- a/examples/llm_server/python/multiplexed_worker_client.py +++ b/examples/llm_server/python/multiplexed_worker_client.py @@ -572,6 +572,18 @@ def _validate_message(msg): raise WorkerError(f"invalid worker {kind} acknowledgement") if "code" in msg and not isinstance(msg["code"], str): raise WorkerError("invalid worker error code") + if "context_overflow" in msg: + counts = msg["context_overflow"] + if ( + kind != "error" + or msg.get("code") != "context_length_exceeded" + or not isinstance(counts, dict) + or set(counts) + != {"prompt_tokens", "context_length", "completion_tokens"} + or any(type(value) is not int or value < 0 for value in counts.values()) + or counts["context_length"] == 0 + ): + raise WorkerError("invalid worker context overflow counts") if kind == "done": MultiplexedWorkerClient._validate_generation_terminal(msg) return kind @@ -669,7 +681,11 @@ def _dispatch(self, msg, kind): state.changed.set() elif kind in (state.ack, "error"): result = ( - WorkerError(msg["error"], code=msg.get("code")) + WorkerError( + msg["error"], + code=msg.get("code"), + context_overflow=msg.get("context_overflow"), + ) if kind == "error" else WorkerStats.from_message(msg) if state.op == "generate" else None ) diff --git a/examples/llm_server/python/protocol.py b/examples/llm_server/python/protocol.py index 2505d601275..e667108a746 100644 --- a/examples/llm_server/python/protocol.py +++ b/examples/llm_server/python/protocol.py @@ -11,11 +11,12 @@ ``examples/llm_server/conformance`` validates them. """ +import json import time import uuid from typing import Any, Literal, Optional, Union -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator def _new_id(prefix: str) -> str: @@ -35,7 +36,7 @@ class ToolCall(BaseModel): class ChatMessage(BaseModel): - role: str + role: Literal["system", "developer", "user", "assistant", "tool"] content: Optional[Union[str, list[dict[str, Any]]]] = None name: Optional[str] = None # Optional prior-turn reasoning (chain-of-thought). Mirrors @@ -54,19 +55,19 @@ class StreamOptions(BaseModel): class ChatCompletionRequest(BaseModel): model: Optional[str] = None - messages: list[ChatMessage] + messages: list[ChatMessage] = Field(min_length=1) stream: bool = False stream_options: Optional[StreamOptions] = None - temperature: Optional[float] = None - top_p: Optional[float] = None - max_tokens: Optional[int] = None - max_completion_tokens: Optional[int] = None + temperature: Optional[float] = Field(default=None, strict=True) + top_p: Optional[float] = Field(default=None, strict=True) + max_tokens: Optional[int] = Field(default=None, strict=True) + max_completion_tokens: Optional[int] = Field(default=None, strict=True) stop: Optional[Union[str, list[str]]] = None - n: int = 1 - seed: Optional[int] = None + n: int = Field(default=1, strict=True) + seed: Optional[int] = Field(default=None, strict=True) frequency_penalty: Optional[float] = None presence_penalty: Optional[float] = None - top_k: Optional[int] = None + top_k: Optional[int] = Field(default=None, strict=True) # Disable model/template termination; explicit request stops still apply. ignore_eos: bool = Field(default=False, strict=True) logit_bias: Optional[dict[str, float]] = None @@ -87,6 +88,52 @@ class ChatCompletionRequest(BaseModel): tools: Optional[list[dict[str, Any]]] = None tool_choice: Optional[Union[str, dict[str, Any]]] = None reasoning_effort: Optional[str] = None + reasoning: Optional[dict[str, Any]] = None + + @field_validator("tools") + @classmethod + def validate_tools(cls, tools): + names = set() + for tool in tools or []: + fn = tool.get("function") + if tool.get("type") != "function" or not isinstance(fn, dict): + raise ValueError("tools must contain function declarations") + name = fn.get("name") + if not isinstance(name, str) or not name.strip() or name in names: + raise ValueError("tool names must be unique nonempty strings") + names.add(name) + if fn.get("parameters") is not None and not isinstance( + fn["parameters"], dict + ): + raise ValueError("tool parameters must be a JSON schema object") + return tools + + @field_validator("messages") + @classmethod + def validate_history(cls, messages): + for message in messages: + calls = set() + for call in message.tool_calls or []: + if message.role != "assistant" or not call.id or not call.function.name: + raise ValueError( + "historical tool calls require assistant role, id and name" + ) + if call.id in calls: + raise ValueError("duplicate historical tool call id") + calls.add(call.id) + try: + arguments = json.loads(call.function.arguments or "{}") + except (ValueError, RecursionError) as error: + raise ValueError( + "historical tool arguments must be valid JSON" + ) from error + if not isinstance(arguments, dict): + raise ValueError("historical tool arguments must be an object") + # Compaction can remove calls/results, and providers can reuse IDs + # across turns. Validate each message without requiring a full log. + if message.role == "tool" and not message.tool_call_id: + raise ValueError("tool result requires a tool_call_id") + return messages def resolved_max_tokens(self) -> int: # `is not None` (not `or`): an explicit 0 must not be treated as unset. @@ -160,6 +207,7 @@ class ModelCard(BaseModel): object: Literal["model"] = "model" created: int = Field(default_factory=lambda: int(time.time())) owned_by: str = "executorch" + context_length: Optional[int] = None class ModelList(BaseModel): diff --git a/examples/llm_server/python/serve.py b/examples/llm_server/python/serve.py index a9a0a3a6f1e..cabe5d363ab 100644 --- a/examples/llm_server/python/serve.py +++ b/examples/llm_server/python/serve.py @@ -9,6 +9,7 @@ import argparse import asyncio import logging +import math import os import shutil from contextlib import asynccontextmanager @@ -24,6 +25,20 @@ _MAX_INT32 = (1 << 31) - 1 +def _parse_preflight_timeout(value): + if value == "none": + return None + try: + timeout = float(value) + except ValueError as error: + raise argparse.ArgumentTypeError( + "expected a nonnegative number or none" + ) from error + if not math.isfinite(timeout) or timeout < 0: + raise argparse.ArgumentTypeError("expected a finite nonnegative number or none") + return timeout + + def _validate_limits(parser, args, *, prefix_capture_slots=0) -> int: for flag, value in ( ("--max-context", args.max_context), @@ -101,6 +116,15 @@ def _parse_args(argv=None): default=0, help="Checkpoint interval in tokens; 0: initial prompt only. Uses global LRU.", ) + parser.add_argument( + "--api-dialect", choices=("openai", "openrouter"), default="openai" + ) + parser.add_argument( + "--streaming-preflight-timeout", + type=_parse_preflight_timeout, + default=None, + help="Seconds to wait for first output (including prefill) before SSE headers; none waits indefinitely.", + ) parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--port", type=int, default=8000) args = parser.parse_args(argv) @@ -187,6 +211,8 @@ async def serving_factory(): args.model_id, max_context=args.max_context, tool_detector_cls=HermesDetector, + api_dialect=args.api_dialect, + streaming_preflight_timeout=args.streaming_preflight_timeout, ) finally: if runtime is None: diff --git a/examples/llm_server/python/server.py b/examples/llm_server/python/server.py index 50f4b7dbbd5..8acc2b057c7 100644 --- a/examples/llm_server/python/server.py +++ b/examples/llm_server/python/server.py @@ -30,10 +30,10 @@ import logging import os from contextlib import asynccontextmanager - from typing import AsyncContextManager, Awaitable, Callable, Optional from fastapi import FastAPI, Header, Request +from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse, Response, StreamingResponse from starlette.requests import ClientDisconnect @@ -81,14 +81,17 @@ def _resolve_session_id( async def _session_op( - op: Callable[[str], Awaitable[None]], session_id: str, ok: dict + op: Callable[[str], Awaitable[None]], + session_id: str, + ok: dict, + serving: ServingChat, ) -> JSONResponse: # Shared shape for the session vendor-extension routes (close/reset): run the # operation, mapping APIError to a structured JSON error. try: await op(session_id) except APIError as e: - return JSONResponse(e.body(), status_code=e.status) + return JSONResponse(serving.error_body(e), status_code=e.status) return JSONResponse(ok) @@ -124,13 +127,57 @@ async def cleanup(): class _ClosingStreamingResponse(StreamingResponse): + def __init__(self, content, *, heartbeat_interval=15.0, **kwargs): + self._source = content + super().__init__(_with_heartbeats(content, heartbeat_interval), **kwargs) + async def __call__(self, scope, receive, send): try: await super().__call__(scope, receive, send) finally: - await SessionRuntime._finish_cleanup( - asyncio.create_task(self.body_iterator.aclose()) - ) + try: + await SessionRuntime._finish_cleanup( + asyncio.create_task(self.body_iterator.aclose()) + ) + finally: + await SessionRuntime._finish_cleanup( + asyncio.create_task(self._source.aclose()) + ) + + +async def _with_heartbeats(stream, interval=15.0): + chunks = asyncio.Queue(maxsize=1) + ready = asyncio.Event() + + async def produce(): + try: + async for chunk in stream: + await chunks.put(chunk) + ready.set() + finally: + await stream.aclose() + + producer = asyncio.create_task(produce()) + producer.add_done_callback(lambda _: ready.set()) + try: + while True: + ready.clear() + try: + chunk = chunks.get_nowait() + except asyncio.QueueEmpty: + if producer.done(): + await producer + return + try: + async with asyncio.timeout(interval): + await ready.wait() + except TimeoutError: + yield ": keep-alive\n\n" + else: + yield chunk + finally: + producer.cancel() + await asyncio.gather(producer, return_exceptions=True) def _create_serving_app(serving, serving_factory): @@ -184,15 +231,33 @@ def build_app( """ app = _create_serving_app(serving, serving_factory) + @app.exception_handler(RequestValidationError) + async def validation_error(request, error): + first = error.errors()[0] + location = ".".join(str(part) for part in first["loc"]) + api_error = APIError( + 422, f"{location}: {first['msg']}", "invalid_request_error", "invalid_value" + ) + return JSONResponse( + _current_serving(app).error_body(api_error), status_code=422 + ) + @app.get("/health") async def health(): if not _current_serving(app).healthy: return JSONResponse({"status": "unavailable"}, status_code=503) return {"status": "ok"} - @app.get("/v1/models") + @app.get("/v1/models", response_model_exclude_none=True) async def list_models() -> ModelList: - return ModelList(data=[ModelCard(id=model_id)]) + return ModelList( + data=[ + ModelCard( + id=model_id, + context_length=_current_serving(app).max_context, + ) + ] + ) @app.post("/v1/chat/completions") async def chat_completions( @@ -217,7 +282,9 @@ async def chat_completions( except ClientDisconnect: return Response(status_code=499) except APIError as e: - return JSONResponse(e.body(), status_code=e.status) + return JSONResponse( + _current_serving(app).error_body(e), status_code=e.status + ) if req.stream: return _ClosingStreamingResponse(result, media_type="text/event-stream") return JSONResponse(result.model_dump(exclude_none=True)) @@ -225,19 +292,23 @@ async def chat_completions( @app.delete("/v1/sessions/{session_id}") async def close_session(session_id: str): # Free a named session's state + capacity slot (vendor extension). + serving = _current_serving(app) return await _session_op( - _current_serving(app).close_session, + serving.close_session, session_id, {"closed": True, "session_id": session_id}, + serving, ) @app.post("/v1/sessions/{session_id}/reset") async def reset_session(session_id: str): # Replace a named session's context under its public ID (vendor extension). + serving = _current_serving(app) return await _session_op( - _current_serving(app).reset_session, + serving.reset_session, session_id, {"reset": True, "session_id": session_id}, + serving, ) return app diff --git a/examples/llm_server/python/serving_chat.py b/examples/llm_server/python/serving_chat.py index 6003ad8f3c7..5c77fd1941a 100644 --- a/examples/llm_server/python/serving_chat.py +++ b/examples/llm_server/python/serving_chat.py @@ -147,6 +147,8 @@ def __init__( streaming_parser_factory: Optional[ Callable[[], StreamingResponseParser] ] = None, + streaming_preflight_timeout: Optional[float] = None, + api_dialect: str = "openai", ): self._runtime = runtime self._template = template @@ -156,6 +158,10 @@ def __init__( self._content_filter = content_filter self._reasoning_extractor = reasoning_extractor self._streaming_parser_factory = streaming_parser_factory + self._streaming_preflight_timeout = streaming_preflight_timeout + if api_dialect not in ("openai", "openrouter"): + raise ValueError("api_dialect must be openai or openrouter") + self.api_dialect = api_dialect # Detector CLASS; a fresh instance is created per request so streaming # state is never shared across concurrent requests. self._tool_detector_cls = tool_detector_cls @@ -184,11 +190,18 @@ def __init__( runtime.max_concurrent_requests if self._multiplexed else None ) + @property + def max_context(self) -> Optional[int]: + return self._max_context + @property def healthy(self) -> bool: """Whether the underlying model worker can accept new requests.""" return self._runtime.healthy + def error_body(self, error: APIError) -> dict: + return error.body(openrouter=self.api_dialect == "openrouter") + @staticmethod def _tool_schemas(req: ChatCompletionRequest) -> dict[str, dict]: """Map each defined tool name to its JSON-schema ``parameters`` object. @@ -345,6 +358,17 @@ def _extract_response(self, req: ChatCompletionRequest, text: str): self._visible_content(text) or None, ) + @staticmethod + def _usage(stats: GenStats) -> Usage: + return Usage( + prompt_tokens=stats.prompt_tokens, + completion_tokens=stats.completion_tokens, + total_tokens=stats.prompt_tokens + stats.completion_tokens, + prompt_tokens_details=PromptTokensDetails( + cached_tokens=stats.reused_prompt_tokens + ), + ) + @staticmethod def _log_generation_stats( session_id: Optional[str], stats: GenStats, finish: str @@ -430,7 +454,19 @@ def _validate_session_id(session_id: str) -> None: @staticmethod def _generation_error(error: Exception) -> APIError: + if isinstance(error, APIError): + return error if isinstance(error, WorkerError): + if ( + error.code == "context_length_exceeded" + and error.context_overflow is not None + ): + counts = error.context_overflow + return ContextLengthExceeded( + counts["prompt_tokens"], + counts["context_length"], + counts["completion_tokens"], + ) if error.code in ("capacity_exhausted", "unsupported_session"): return SessionCapacity(error.code) if error.code is not None: @@ -439,6 +475,7 @@ def _generation_error(error: Exception) -> APIError: "session_busy": 409, "not_ready": 503, "invalid_argument": 400, + "context_length_exceeded": 400, "unsupported_parameter": 400, }.get(error.code, 500) return APIError( @@ -594,6 +631,7 @@ def _reject_unsupported_params(self, req: ChatCompletionRequest) -> None: ), (req.n != 1, "n>1"), (req.reasoning_effort is not None, "reasoning_effort"), + (req.reasoning not in (None, {}, {"exclude": False}), "reasoning"), (bool(req.frequency_penalty), "frequency_penalty"), (bool(req.presence_penalty), "presence_penalty"), (bool(req.logit_bias), "logit_bias"), @@ -691,12 +729,12 @@ async def _create(self, req: ChatCompletionRequest, lease): if self._max_context: count = self._count_prompt_tokens(prompt_input) if count is not None: + requested = req.resolved_max_tokens() if count >= self._max_context: - raise ContextLengthExceeded(count, self._max_context) + raise ContextLengthExceeded(count, self._max_context, requested) # An explicit max_tokens that wouldn't fit alongside the prompt # must be rejected here, not run until the worker hits the context # limit mid-decode (a 500 / streaming error after partial output). - requested = req.resolved_max_tokens() if requested > 0 and count + requested > self._max_context: raise ContextLengthExceeded(count, self._max_context, requested) # Per-path worker stop set (see __init__ for the two sets and why): tool @@ -728,7 +766,7 @@ async def _create_response( req.session_id, prompt_input, options, stats ) if self._multiplexed: - await generation.wait_ready() + await generation.wait_ready(timeout=self._streaming_preflight_timeout) return _ChatStream( self._stream(req, preamble, gen_stops, generation, stats), lease, @@ -790,14 +828,7 @@ async def _complete( finish_reason=finish, ) ], - usage=Usage( - prompt_tokens=stats.prompt_tokens, - completion_tokens=stats.completion_tokens, - total_tokens=stats.prompt_tokens + stats.completion_tokens, - prompt_tokens_details=PromptTokensDetails( - cached_tokens=stats.reused_prompt_tokens - ), - ), + usage=self._usage(stats), ) async def _stream_plain_content( @@ -951,6 +982,14 @@ def chunk(delta: DeltaMessage, finish=None) -> str: id=cid, model=self._model_id, choices=[ChunkChoice(delta=delta, finish_reason=finish)], + usage=( + self._usage(stats) + if finish is not None + and self.api_dialect == "openrouter" + and req.stream_options + and req.stream_options.include_usage + else None + ), ) return f"data: {c.model_dump_json(exclude_none=True)}\n\n" @@ -1016,7 +1055,7 @@ def chunk(delta: DeltaMessage, finish=None) -> str: await generation.aclose() if error is not None: - err = self._generation_error(error).body() + err = self.error_body(self._generation_error(error)) yield f"data: {json.dumps(err)}\n\n" yield "data: [DONE]\n\n" return @@ -1042,19 +1081,16 @@ def chunk(delta: DeltaMessage, finish=None) -> str: chunk, ): yield final_chunk - if req.stream_options and req.stream_options.include_usage: + if ( + req.stream_options + and req.stream_options.include_usage + and self.api_dialect != "openrouter" + ): usage_chunk = ChatCompletionChunk( id=cid, model=self._model_id, choices=[], - usage=Usage( - prompt_tokens=stats.prompt_tokens, - completion_tokens=stats.completion_tokens, - total_tokens=stats.prompt_tokens + stats.completion_tokens, - prompt_tokens_details=PromptTokensDetails( - cached_tokens=stats.reused_prompt_tokens - ), - ), + usage=self._usage(stats), ) yield f"data: {usage_chunk.model_dump_json(exclude_none=True)}\n\n" yield "data: [DONE]\n\n" diff --git a/examples/llm_server/python/session_runtime.py b/examples/llm_server/python/session_runtime.py index a0cabc5b12d..3b4a2e98886 100644 --- a/examples/llm_server/python/session_runtime.py +++ b/examples/llm_server/python/session_runtime.py @@ -369,11 +369,13 @@ async def __anext__(self): self._reader = None self._read_done.set_result(None) - async def wait_ready(self) -> None: + async def wait_ready(self, timeout: Optional[float] = None) -> None: """Start generation and retain its first raw token, terminal, or error. One prefetch task/token per admitted request lets HTTP map admission errors before sending headers without pre-opening native sessions. + A finite timeout permits headers before readiness; subsequent errors + are then reported in the response stream. """ if self._closed: return @@ -384,6 +386,10 @@ async def wait_ready(self) -> None: if self._runtime._native: self._runtime._own_task(self._prefetch) try: + if timeout is not None: + done, _ = await asyncio.wait((self._prefetch,), timeout=timeout) + if not done: + return # Late admission errors are delivered in the SSE stream. await asyncio.shield(self._prefetch) except StopAsyncIteration: self._closed = True diff --git a/examples/llm_server/python/tests/conftest.py b/examples/llm_server/python/tests/conftest.py index b2d1f05be93..045e3cf723d 100644 --- a/examples/llm_server/python/tests/conftest.py +++ b/examples/llm_server/python/tests/conftest.py @@ -4,19 +4,26 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. -"""Fixtures for hermetic contract tests. +"""Fixtures for serving contract tests. -We build a SessionRuntime over a single FakeRunner worker, so the tests exercise -the real server, protocol, templating, and streaming code over the HTTP boundary -with NO model, tokenizer, GPU, or worker subprocess. This mirrors ExecuTorch's -fake_llm_executor approach: fake the worker, test the real surface. - -The control plane imports no runtime pybind; only fastapi, pydantic, httpx, and -pytest are required. +Most tests use SessionRuntime over a fake worker to exercise the HTTP boundary +without a model or GPU. Tests depending on native_worker are marked for a +separate CI pass after the native executables have been built. """ +import asyncio +import json +import os +import socket +from contextlib import asynccontextmanager +from pathlib import Path +from types import SimpleNamespace + import pytest from executorch.examples.llm_server.python.chat_template import ChatTemplate +from executorch.examples.llm_server.python.multiplexed_worker_client import ( + MultiplexedWorkerClient, +) from executorch.examples.llm_server.python.server import build_app from executorch.examples.llm_server.python.serving_chat import ServingChat from executorch.examples.llm_server.python.session_runtime import SessionRuntime @@ -25,6 +32,75 @@ from fastapi.testclient import TestClient +def pytest_configure(config): + config.addinivalue_line( + "markers", "native_worker: requires a native test worker executable" + ) + + +def pytest_collection_modifyitems(items): + for item in items: + if "native_worker" in item.fixturenames: + item.add_marker(pytest.mark.native_worker) + + +@pytest.fixture(scope="module") +def native_worker(): + """Require an explicitly selected, freshly built native test executable.""" + executable = os.environ.get("EXECUTORCH_TEST_MULTIPLEXED_WORKER") + if not executable: + pytest.skip("set EXECUTORCH_TEST_MULTIPLEXED_WORKER to the native test target") + assert Path(executable).is_file(), executable + return executable + + +@pytest.fixture +def active_peer(native_worker): + @asynccontextmanager + async def start(): + loop = asyncio.get_running_loop() + parent, child = socket.socketpair() + parent.setblocking(False) + proc = None + client = None + try: + proc = await asyncio.create_subprocess_exec( + native_worker, + "--validation-worker", + str(child.fileno()), + pass_fds=(child.fileno(),), + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + limit=1024 * 1024, + ) + child.close() + ready = json.loads(await proc.stdout.readline()) + assert ready["ready"] and ready["multiplexed"] + client = MultiplexedWorkerClient( + proc, + max_named_sessions=ready["max_named_sessions"], + max_inflight_requests=2, + ) + peer = client.generate("active peer", SimpleNamespace(max_new_tokens=3)) + # Native execute() is blocked on this socket until explicitly released. + assert await loop.sock_recv(parent, 1) == b"A" + assert not peer._state.completion.done() + yield client, peer, parent + finally: + parent.close() + child.close() + try: + if client is not None: + await client.close() + finally: + if proc is not None: + if proc.returncode is None: + proc.kill() + await proc.wait() + + return start + + class _FakeStats: num_prompt_tokens = 5 num_generated_tokens = 0 @@ -137,6 +213,7 @@ def _make( streaming_parser_factory=None, content_filter=None, special_tokens=None, + api_dialect="openai", ): fake = FakeRunner( tokens, @@ -167,6 +244,7 @@ def _make( reasoning_extractor=reasoning_extractor, streaming_parser_factory=streaming_parser_factory, content_filter=content_filter, + api_dialect=api_dialect, ) return TestClient(build_app(serving, "test-model")), fake diff --git a/examples/llm_server/python/tests/test_multiplexed_worker_client_native.py b/examples/llm_server/python/tests/test_multiplexed_worker_client_native.py index cc044516e46..32c17d005a0 100644 --- a/examples/llm_server/python/tests/test_multiplexed_worker_client_native.py +++ b/examples/llm_server/python/tests/test_multiplexed_worker_client_native.py @@ -11,75 +11,13 @@ """ import asyncio -import json -import os -import socket -from contextlib import asynccontextmanager -from pathlib import Path from types import SimpleNamespace from unittest.mock import patch import pytest - -from executorch.examples.llm_server.python.multiplexed_worker_client import ( - MultiplexedWorkerClient, -) from executorch.examples.llm_server.python.worker_client import WorkerError -@pytest.fixture(scope="module") -def native_worker(): - """Require an explicitly selected, freshly built native test executable.""" - executable = os.environ.get("EXECUTORCH_TEST_MULTIPLEXED_WORKER") - if not executable: - pytest.skip("set EXECUTORCH_TEST_MULTIPLEXED_WORKER to the native test target") - assert Path(executable).is_file(), executable - return executable - - -@asynccontextmanager -async def _active_peer(executable): - loop = asyncio.get_running_loop() - parent, child = socket.socketpair() - parent.setblocking(False) - proc = None - client = None - try: - proc = await asyncio.create_subprocess_exec( - executable, - "--validation-worker", - str(child.fileno()), - pass_fds=(child.fileno(),), - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - limit=1024 * 1024, - ) - child.close() - ready = json.loads(await proc.stdout.readline()) - assert ready["ready"] and ready["multiplexed"] - client = MultiplexedWorkerClient( - proc, - max_named_sessions=ready["max_named_sessions"], - max_inflight_requests=2, - ) - peer = client.generate("active peer", SimpleNamespace(max_new_tokens=3)) - # Native execute() is blocked on this socket until explicitly released. - assert await loop.sock_recv(parent, 1) == b"A" - assert not peer._state.completion.done() - yield client, peer, parent - finally: - parent.close() - child.close() - try: - if client is not None: - await client.close() - finally: - if proc is not None: - if proc.returncode is None: - proc.kill() - await proc.wait() - - async def _finish_peer_and_reuse(client, peer, gate): assert not peer._state.completion.done() assert client.healthy and not client.failed @@ -133,12 +71,12 @@ async def _finish_peer_and_reuse(client, peer, gate): ], ) def test_invalid_request_is_local_with_native_peer( - native_worker, reserved, prompt, config + active_peer, reserved, prompt, config ): """Reject parser-unsafe values before writing, without failing active work.""" async def scenario(): - async with _active_peer(native_worker) as (client, peer, gate): + async with active_peer() as (client, peer, gate): request_id = client.reserve_request() if reserved else None # Observe actual stdin writes; do not replace the transport or parser. with patch.object( @@ -163,11 +101,11 @@ async def bounded(): @pytest.mark.parametrize( "operation", ["open_session", "reset_session", "close_session"] ) -def test_invalid_lifecycle_is_local_with_native_peer(native_worker, operation): +def test_invalid_lifecycle_is_local_with_native_peer(active_peer, operation): """Apply the same local encoding checks to session control operations.""" async def scenario(): - async with _active_peer(native_worker) as (client, peer, gate): + async with active_peer() as (client, peer, gate): with patch.object( client._proc.stdin, "write", wraps=client._proc.stdin.write ) as write: @@ -185,11 +123,11 @@ async def bounded(): asyncio.run(bounded()) -def test_valid_non_bmp_nested_values_and_integer_boundaries(native_worker): +def test_valid_non_bmp_nested_values_and_integer_boundaries(active_peer): """Keep native-representable Unicode and integer endpoints usable.""" async def scenario(): - async with _active_peer(native_worker) as (client, peer, gate): + async with active_peer() as (client, peer, gate): await _finish_peer_and_reuse(client, peer, gate) stream = client.generate( "unused", diff --git a/examples/llm_server/python/tests/test_openrouter_recovery.py b/examples/llm_server/python/tests/test_openrouter_recovery.py new file mode 100644 index 00000000000..b344ab0fdbb --- /dev/null +++ b/examples/llm_server/python/tests/test_openrouter_recovery.py @@ -0,0 +1,110 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import json +import re + +import pytest +from executorch.examples.llm_server.python.worker_client import WorkerError + + +def muse_overflow_counts(body): + error = body["error"] + assert error["metadata"]["error_type"] == "context_length_exceeded" + # Muse's OpenRouter contract: metadata classifies it, message gives the + # input size and usable limit for its one-shot changed-input retry. + match = re.match( + r"This model's maximum context length is (\d+) tokens\. " + r"However, your messages resulted in (\d+) tokens[. (]", + error["message"], + ) + return tuple(map(int, match.groups())) if match else None + + +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize( + "prompt,completion,budget", + [(110, None, 99), (100, None, 99), (80, 30, 70), (110, 30, 70)], +) +def test_openrouter_http_overflow_supplies_actionable_prompt_counts( + make_client, stream, prompt, completion, budget +): + client, worker = make_client( + max_context=100, prompt_tokens=prompt, api_dialect="openrouter" + ) + response = client.post( + "/v1/chat/completions", + json={ + "messages": [{"role": "user", "content": "hi"}], + "stream": stream, + **({"max_tokens": completion} if completion else {}), + }, + ) + assert response.status_code == 400 + assert muse_overflow_counts(response.json()) == (budget, prompt) + assert response.json()["error"]["metadata"]["context_length"] == 100 + assert worker.captured_config is None + + +@pytest.mark.parametrize("prompt", [20, 110]) +def test_impossible_output_reservation_does_not_invent_recoverable_counts( + make_client, prompt +): + client, _ = make_client( + max_context=100, prompt_tokens=prompt, api_dialect="openrouter" + ) + response = client.post( + "/v1/chat/completions", + json={"messages": [{"role": "user", "content": "hi"}], "max_tokens": 100}, + ) + assert response.status_code == 400 + assert muse_overflow_counts(response.json()) is None + + +@pytest.mark.parametrize("completion", [None, 30]) +def test_default_openai_error_contract_is_unchanged(make_client, completion): + client, _ = make_client(max_context=100, prompt_tokens=110) + body = {"messages": [{"role": "user", "content": "hi"}]} + if completion is not None: + body["max_tokens"] = completion + response = client.post("/v1/chat/completions", json=body) + message = "This model's maximum context length is 100 tokens, but the request has 110 prompt tokens." + assert response.json() == { + "error": { + "message": message, + "type": "invalid_request_error", + "code": "context_length_exceeded", + } + } + + +def test_late_context_error_has_same_openrouter_contract(make_client): + client, worker = make_client(api_dialect="openrouter") + + def fail(*args, **kwargs): + raise WorkerError( + "context length exceeded", + code="context_length_exceeded", + context_overflow={ + "prompt_tokens": 110, + "context_length": 100, + "completion_tokens": 1, + }, + ) + + worker.generate = fail + response = client.post( + "/v1/chat/completions", + json={"messages": [{"role": "user", "content": "hi"}], "stream": True}, + ) + assert response.status_code == 200 + errors = [ + json.loads(line[6:]) + for line in response.text.splitlines() + if line.startswith('data: {"error"') + ] + assert len(errors) == 1 + assert muse_overflow_counts(errors[0]) == (99, 110) + assert '"finish_reason"' not in response.text diff --git a/examples/llm_server/python/tests/test_serve_launcher.py b/examples/llm_server/python/tests/test_serve_launcher.py index d1725069c1d..bbaa097bd2a 100644 --- a/examples/llm_server/python/tests/test_serve_launcher.py +++ b/examples/llm_server/python/tests/test_serve_launcher.py @@ -240,6 +240,8 @@ def test_default_launch_requires_multiplexing_and_shares_context(argv, launch): "executorch", max_context=1024, tool_detector_cls=HermesDetector, + api_dialect="openai", + streaming_preflight_timeout=None, ) app = launch.stages["uvicorn"].call_args.args[0] launch.stages["uvicorn"].assert_called_once_with(app, host="127.0.0.1", port=8000) @@ -810,3 +812,18 @@ async def scenario(app): launch.stages["uvicorn"].side_effect = lambda app, **kwargs: _run(scenario(app)) serve.main(argv) + + +@pytest.mark.parametrize("value,expected", [("none", None), ("0", 0.0), ("0.25", 0.25)]) +def test_api_dialect_and_preflight_options(argv, value, expected): + args = serve._parse_args( + argv + ["--api-dialect", "openrouter", "--streaming-preflight-timeout", value] + ) + assert args.api_dialect == "openrouter" + assert args.streaming_preflight_timeout == expected + + +@pytest.mark.parametrize("value", ["-1", "inf", "nan", "invalid"]) +def test_invalid_preflight_option(argv, value): + with pytest.raises(SystemExit): + serve._parse_args(argv + ["--streaming-preflight-timeout", value]) diff --git a/examples/llm_server/python/tests/test_serving_regressions.py b/examples/llm_server/python/tests/test_serving_regressions.py new file mode 100644 index 00000000000..05c08abc839 --- /dev/null +++ b/examples/llm_server/python/tests/test_serving_regressions.py @@ -0,0 +1,645 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import asyncio +import json + +import pytest +from executorch.examples.llm_server.python.chat_template import ChatTemplate +from executorch.examples.llm_server.python.protocol import ChatMessage +from executorch.examples.llm_server.python.server import _with_heartbeats + + +def test_unknown_context_is_omitted_from_model_metadata(make_client): + client, _ = make_client() + assert "context_length" not in client.get("/v1/models").json()["data"][0] + + +@pytest.mark.parametrize("failed", [False, True]) +def test_heartbeat_cleanup_while_waiting_for_source(failed): + async def scenario(): + gate = asyncio.Event() + closed = [] + + async def source(): + try: + await gate.wait() + raise RuntimeError("source failed") + yield + finally: + closed.append(True) + + stream = _with_heartbeats(source(), interval=0.001) + assert await anext(stream) == ": keep-alive\n\n" + if failed: + gate.set() + with pytest.raises(RuntimeError, match="source failed"): + await anext(stream) + await stream.aclose() + assert closed == [True] + + asyncio.run(scenario()) + + +@pytest.mark.parametrize( + "body", + [ + {"messages": []}, + {"messages": [{"role": "bogus", "content": "keep me"}]}, + {"tools": [{"type": "function", "function": {"name": []}}]}, + {"tools": [{"type": "function", "function": "bad"}]}, + {"max_tokens": True}, + {"max_tokens": "12"}, + { + "messages": [ + { + "role": "assistant", + "tool_calls": [ + { + "id": "duplicate", + "function": {"name": "f", "arguments": "{}"}, + } + for _ in range(2) + ], + } + ] + }, + {"messages": [{"role": "tool", "content": "result"}]}, + { + "messages": [ + { + "role": "assistant", + "tool_calls": [ + {"id": "x", "function": {"name": "f", "arguments": "{broken"}} + ], + } + ] + }, + ], +) +def test_bad_structures_are_rejected_before_generation(make_client, body): + client, worker = make_client() + response = client.post( + "/v1/chat/completions", + json={"messages": [{"role": "user", "content": "hi"}], **body}, + ) + assert response.status_code == 422 + assert worker.captured_config is None + + +def test_model_metadata_reports_context_limit(make_client): + client, _ = make_client(max_context=65536) + model = client.get("/v1/models").json()["data"][0] + assert model["context_length"] == 65536 + + +def test_heartbeat_does_not_cancel_or_duplicate_pending_token(): + async def scenario(): + gate = asyncio.Event() + closed = [] + + async def source(): + try: + await gate.wait() + yield "data: token\n\n" + finally: + closed.append(True) + + stream = _with_heartbeats(source(), interval=0.01) + assert await anext(stream) == ": keep-alive\n\n" + gate.set() + assert await anext(stream) == "data: token\n\n" + with pytest.raises(StopAsyncIteration): + await anext(stream) + assert closed == [True] + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("failed", [False, True]) +def test_bounded_streaming_preflight_keeps_generation_alive(failed): + import threading + + from executorch.examples.llm_server.python.protocol import ChatCompletionRequest + from executorch.examples.llm_server.python.serving_chat import ServingChat + from executorch.examples.llm_server.python.session_runtime import SessionRuntime + from executorch.examples.llm_server.python.tests.test_concurrent_serving import ( + _Worker, + ) + + class SlowPrefill(_Worker): + def __init__(self): + super().__init__() + self.prefill = threading.Event() + + def generate(self, prompt, config, token_callback, stats_callback, request_id): + self.calls.put(("prefill", request_id)) + assert self.prefill.wait(5) + if failed: + from executorch.examples.llm_server.python.worker_client import ( + WorkerError, + ) + + raise WorkerError("late worker error", code="invalid_argument") + return super().generate( + prompt, config, token_callback, stats_callback, request_id + ) + + async def scenario(): + worker = SlowPrefill() + runtime = SessionRuntime(worker) + serving = ServingChat( + runtime, + ChatTemplate(allow_fallback=True), + "test", + streaming_preflight_timeout=0.02, + ) + stream = None + try: + stream = await asyncio.wait_for( + serving.create( + ChatCompletionRequest( + messages=[ChatMessage(role="user", content="hi")], stream=True + ) + ), + timeout=1, + ) + assert '"role":"assistant"' in await anext(stream) + _, request_id = worker.calls.get_nowait() + assert not worker.cancelled + worker.prefill.set() + worker.finish(request_id) + output = "".join([chunk async for chunk in stream]) + assert ("invalid_argument" if failed else "reply") in output + assert "[DONE]" in output + finally: + worker.prefill.set() + if stream is not None: + await stream.aclose() + await runtime.aclose_worker() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("inline", [False, True]) +@pytest.mark.parametrize("include_usage", [False, True]) +def test_openrouter_usage_arrives_before_client_stops_at_finish( + make_client, inline, include_usage +): + import json + + client, _ = make_client( + api_dialect="openrouter" if inline else "openai", tokens=["one", "two"], reuse=2 + ) + response = client.post( + "/v1/chat/completions", + json={ + "messages": [{"role": "user", "content": "hi"}], + "stream": True, + "stream_options": {"include_usage": include_usage}, + }, + ) + chunks = [ + json.loads(line[6:]) + for line in response.text.splitlines() + if line.startswith("data: ") and line != "data: [DONE]" + ] + usage_chunks = [c for c in chunks if c.get("usage")] + assert len(usage_chunks) == int(include_usage) + if include_usage: + usage_chunk = usage_chunks[0] + assert usage_chunk["usage"]["completion_tokens"] == 2 + assert usage_chunk["usage"]["prompt_tokens_details"]["cached_tokens"] == 2 + if inline: + assert usage_chunk["choices"][0]["finish_reason"] + assert chunks[-1] is usage_chunk + else: + assert usage_chunk["choices"] == [] + + +@pytest.mark.parametrize("arguments", ["", None, "{}"]) +def test_empty_historical_arguments_and_null_schema_are_accepted( + make_client, arguments +): + client, _ = make_client() + response = client.post( + "/v1/chat/completions", + json={ + "messages": [ + { + "role": "assistant", + "tool_calls": [ + { + "id": "call_1", + "function": {"name": "f", "arguments": arguments}, + } + ], + } + ], + "tools": [ + {"type": "function", "function": {"name": "f", "parameters": None}} + ], + }, + ) + assert response.status_code == 200, response.text + + +@pytest.mark.parametrize("dialect", ["openai", "openrouter"]) +def test_hermes_encoded_object_round_trips(make_client, dialect): + raw = ( + "" + + json.dumps({"name": "f", "arguments": json.dumps({"city": "Paris"})}) + + "" + ) + client, _ = make_client(tokens=[raw], api_dialect=dialect) + body = { + "messages": [{"role": "user", "content": "weather"}], + "tools": [{"type": "function", "function": {"name": "f", "parameters": {}}}], + } + first = client.post("/v1/chat/completions", json=body) + assert first.status_code == 200 + message = first.json()["choices"][0]["message"] + assert json.loads(message["tool_calls"][0]["function"]["arguments"]) == { + "city": "Paris" + } + body["messages"].extend( + [ + message, + { + "role": "tool", + "tool_call_id": message["tool_calls"][0]["id"], + "content": "sunny", + }, + ] + ) + assert client.post("/v1/chat/completions", json=body).status_code == 200 + error = client.post( + "/v1/chat/completions", json={"messages": [], "max_tokens": True} + ) + assert error.status_code == 422 + assert error.json()["error"]["code"] == "invalid_value" + + +@pytest.mark.parametrize("arguments", [[], 42, "not json", "[]"]) +def test_hermes_nonobject_arguments_are_not_emitted(make_client, arguments): + raw = ( + "" + + json.dumps({"name": "f", "arguments": arguments}) + + "" + ) + client, _ = make_client(tokens=[raw]) + response = client.post( + "/v1/chat/completions", + json={ + "messages": [{"role": "user", "content": "hi"}], + "tools": [{"type": "function", "function": {"name": "f"}}], + }, + ) + assert response.status_code == 200 + assert not response.json()["choices"][0]["message"].get("tool_calls") + + +@pytest.mark.parametrize("reasoning", [{}, {"exclude": False}]) +def test_noop_reasoning_is_accepted(make_client, reasoning): + client, _ = make_client() + assert ( + client.post( + "/v1/chat/completions", + json={ + "messages": [{"role": "user", "content": "hi"}], + "reasoning": reasoning, + }, + ).status_code + == 200 + ) + + +def test_template_input_errors_use_structured_400(make_client): + from types import SimpleNamespace + + from jinja2 import TemplateError + + client, _ = make_client() + + def render(*args, **kwargs): + raise TemplateError("unsupported message ordering") + + client.app.state.serving._template._hf = SimpleNamespace(apply_chat_template=render) + response = client.post( + "/v1/chat/completions", json={"messages": [{"role": "user", "content": "hi"}]} + ) + assert response.status_code == 400 + assert response.json()["error"]["code"] == "invalid_value" + + +def test_closing_response_sends_heartbeat_and_closes_source(): + from executorch.examples.llm_server.python.server import _ClosingStreamingResponse + + async def scenario(): + gate = asyncio.Event() + closed = [] + bodies = [] + + async def source(): + try: + await gate.wait() + yield "data: [DONE]\n\n" + finally: + closed.append(True) + + async def receive(): + await asyncio.Event().wait() + + async def send(message): + if message["type"] == "http.response.body": + bodies.append(message.get("body", b"")) + gate.set() + + response = _ClosingStreamingResponse(source(), heartbeat_interval=0.001) + await asyncio.wait_for( + response({"type": "http", "asgi": {"spec_version": "2.4"}}, receive, send), + 1, + ) + assert bodies[:2] == [b": keep-alive\n\n", b"data: [DONE]\n\n"] + assert closed == [True] + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("arguments", ["", None, "omitted"]) +@pytest.mark.parametrize( + "render_args", + [ + "{{ call.function.arguments | items | list | tojson }}", + "{{ call.function.arguments | tojson }}", + ], +) +def test_empty_arguments_render_as_objects_in_hf_templates( + make_client, monkeypatch, arguments, render_args +): + from tokenizers import Tokenizer + from tokenizers.models import WordLevel + from transformers import AutoTokenizer, PreTrainedTokenizerFast + + tokenizer = PreTrainedTokenizerFast( + tokenizer_object=Tokenizer(WordLevel({"[UNK]": 0}, unk_token="[UNK]")) + ) + tokenizer.chat_template = ( + "{% for message in messages %}{% for call in message.tool_calls | default([]) %}" + + render_args + + "{% endfor %}{% endfor %}" + ) + monkeypatch.setattr(AutoTokenizer, "from_pretrained", lambda _: tokenizer) + client, worker = make_client() + client.app.state.serving._template = ChatTemplate("local") + function = {"name": "f"} + if arguments != "omitted": + function["arguments"] = arguments + response = client.post( + "/v1/chat/completions", + json={ + "messages": [ + { + "role": "assistant", + "tool_calls": [{"id": "c", "function": function}], + }, + {"role": "tool", "tool_call_id": "c", "content": "done"}, + ] + }, + ) + assert response.status_code == 200, response.text + expected = "[]" if "items" in render_args else "{}" + assert ( + client.app.state.serving._template.render( + [ + ChatMessage( + role="assistant", tool_calls=[{"id": "c", "function": function}] + ) + ] + ) + == expected + ) + assert worker.captured_config is not None + + +def test_hermes_empty_arguments_preserve_the_complete_call_set(make_client): + raw = '[{"name":"f","arguments":""},{"name":"f","arguments":{}}]' + client, _ = make_client(tokens=[raw]) + response = client.post( + "/v1/chat/completions", + json={ + "messages": [{"role": "user", "content": "call f twice"}], + "tools": [ + {"type": "function", "function": {"name": "f", "parameters": {}}} + ], + }, + ) + assert response.status_code == 200 + calls = response.json()["choices"][0]["message"]["tool_calls"] + assert [json.loads(c["function"]["arguments"]) for c in calls] == [{}, {}] + + +def test_heartbeat_disconnect_closes_real_generation_and_session_lease(): + from executorch.examples.llm_server.python.protocol import ChatCompletionRequest + from executorch.examples.llm_server.python.server import _ClosingStreamingResponse + from executorch.examples.llm_server.python.serving_chat import ServingChat + from executorch.examples.llm_server.python.session_runtime import SessionRuntime + from executorch.examples.llm_server.python.tests.test_concurrent_serving import ( + _NativeWorker, + ) + + async def scenario(): + worker = _NativeWorker(tokens=()) + runtime = SessionRuntime(worker) + serving = ServingChat( + runtime, + ChatTemplate(allow_fallback=True), + "test", + streaming_preflight_timeout=0, + ) + disconnected = asyncio.Event() + bodies = [] + + async def receive(): + await disconnected.wait() + return {"type": "http.disconnect"} + + async def send(message): + if message["type"] == "http.response.body": + bodies.append(message.get("body", b"")) + if bodies[-1] == b": keep-alive\n\n": + disconnected.set() + + try: + stream = await serving.create( + ChatCompletionRequest( + messages=[ChatMessage(role="user", content="hi")], + session_id="s", + stream=True, + ) + ) + response = _ClosingStreamingResponse(stream, heartbeat_interval=0.001) + await asyncio.wait_for( + response( + {"type": "http", "asgi": {"spec_version": "2.0"}}, receive, send + ), + 2, + ) + assert b": keep-alive\n\n" in bodies + assert len(worker.cancelled) == 1 + assert runtime._admitted == 0 + + async def reacquire(): + async with runtime._session_locks.hold("s"): + async with serving._transactions.hold("s"): + pass + + await asyncio.wait_for(reacquire(), 1) + finally: + await runtime.aclose_worker() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("dialect", ["openai", "openrouter"]) +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize( + "history", + [ + "orphan", + "summarized", + "reused-id-missing-result", + "duplicate-result", + "complete-reused-id", + ], +) +def test_compacted_tool_histories_preserve_messages( + make_client, monkeypatch, dialect, stream, history +): + from copy import deepcopy + + from tokenizers import Tokenizer + from tokenizers.models import WordLevel + from transformers import AutoTokenizer, PreTrainedTokenizerFast + + call = { + "role": "assistant", + "tool_calls": [ + { + "id": "call_0", + "type": "function", + "function": {"name": "read", "arguments": '{"path":"a.py"}'}, + } + ], + } + result = { + "role": "tool", + "tool_call_id": "call_0", + "name": "read", + "content": "file contents", + } + if history == "orphan": + messages = [result] + elif history == "summarized": + messages = [ + {"role": "system", "content": "Earlier work was compacted."}, + result, + ] + elif history == "reused-id-missing-result": + messages = [ + call, + {"role": "user", "content": "Earlier result was summarized. Read again."}, + deepcopy(call), + result, + ] + elif history == "duplicate-result": + messages = [call, result, deepcopy(result)] + else: + messages = [ + call, + result, + {"role": "user", "content": "Read again."}, + deepcopy(call), + deepcopy(result), + ] + messages.append({"role": "user", "content": "Continue from the retained results."}) + tokenizer = PreTrainedTokenizerFast( + tokenizer_object=Tokenizer(WordLevel({"[UNK]": 0}, unk_token="[UNK]")) + ) + tokenizer.chat_template = "{{ messages | tojson }}" + monkeypatch.setattr(AutoTokenizer, "from_pretrained", lambda _: tokenizer) + client, worker = make_client(api_dialect=dialect) + client.app.state.serving._template = ChatTemplate("local") + prompts = [] + generate = worker.generate + + def capture(prompt, *args, **kwargs): + prompts.append(json.loads(prompt)) + return generate(prompt, *args, **kwargs) + + monkeypatch.setattr(worker, "generate", capture) + response = client.post( + "/v1/chat/completions", json={"messages": messages, "stream": stream} + ) + assert response.status_code == 200, response.text + assert len(prompts) == 1 + rendered = prompts[0] + assert len(rendered) == len(messages) + for original, actual in zip(messages, rendered): + for key in ("role", "content", "name", "tool_call_id"): + if key in original: + assert actual[key] == original[key] + if original.get("tool_calls"): + assert actual["tool_calls"][0]["id"] == "call_0" + assert actual["tool_calls"][0]["function"] == { + "name": "read", + "arguments": {"path": "a.py"}, + } + + +@pytest.mark.parametrize("close_early", [False, True]) +def test_heartbeat_source_and_cleanup_stay_in_one_task_with_bounded_read_ahead( + close_early, +): + async def scenario(): + owners = set() + produced = [] + closed = [] + + async def source(): + try: + for i in range(100): + owners.add(asyncio.current_task()) + produced.append(i) + yield str(i) + finally: + owners.add(asyncio.current_task()) + closed.append(True) + + stream = _with_heartbeats(source()) + assert await anext(stream) == "0" + if close_early: + for _ in range(5): + await asyncio.sleep(0) + assert len(produced) <= 3 + await stream.aclose() + else: + assert [chunk async for chunk in stream] == [str(i) for i in range(1, 100)] + assert closed == [True] + assert len(owners) == 1 + + asyncio.run(scenario()) + + +def test_heartbeat_propagates_source_cancellation(): + async def scenario(): + async def source(): + raise asyncio.CancelledError() + yield + + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(anext(_with_heartbeats(source())), 1) + + asyncio.run(scenario()) diff --git a/examples/llm_server/python/tests/test_sessions.py b/examples/llm_server/python/tests/test_sessions.py index 3d1b1354f4c..c0d792d4511 100644 --- a/examples/llm_server/python/tests/test_sessions.py +++ b/examples/llm_server/python/tests/test_sessions.py @@ -396,7 +396,7 @@ def test_stop_set_narrow_but_strip_set_broad(ignore_eos): ) req = ChatCompletionRequest( model="test-model", - messages=[], + messages=[{"role": "user", "content": "hi"}], ignore_eos=ignore_eos, tools=[{"type": "function", "function": {"name": "f"}}], ) diff --git a/examples/llm_server/python/tests/test_worker_context_overflow.py b/examples/llm_server/python/tests/test_worker_context_overflow.py new file mode 100644 index 00000000000..9d922a157e9 --- /dev/null +++ b/examples/llm_server/python/tests/test_worker_context_overflow.py @@ -0,0 +1,123 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import asyncio +import json + +import httpx +import pytest +from executorch.examples.llm_server.python.chat_template import ChatTemplate +from executorch.examples.llm_server.python.multiplexed_worker_client import ( + MultiplexedWorkerClient, +) +from executorch.examples.llm_server.python.protocol import ChatMessage +from executorch.examples.llm_server.python.server import build_app +from executorch.examples.llm_server.python.serving_chat import ServingChat +from executorch.examples.llm_server.python.session_runtime import SessionRuntime +from executorch.examples.llm_server.python.worker_client import WorkerError + + +@pytest.mark.parametrize("dialect", ["openai", "openrouter"]) +@pytest.mark.parametrize("stream,timeout", [(False, None), (True, None), (True, 0)]) +def test_native_overflow_counts_reach_http(active_peer, dialect, stream, timeout): + async def scenario(): + async with active_peer() as (worker, peer, gate): + await asyncio.get_running_loop().sock_sendall(gate, b"R") + async for _ in peer: + pass + await peer.wait() + template = ChatTemplate(allow_fallback=True) + # No HF tokenizer: only the native tokenizer knows the true size. + serving = ServingChat( + SessionRuntime(worker), + template, + "test", + max_context=128, + api_dialect=dialect, + streaming_preflight_timeout=timeout, + ) + messages = [ChatMessage(role="user", content="x" * 200)] + expected = len(template.render(messages).encode("utf-8")) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=build_app(serving, "test")), + base_url="http://test", # @lint-ignore: in-process ASGI transport + ) as client: + response = await client.post( + "/v1/chat/completions", + json={ + "messages": [m.model_dump(exclude_none=True) for m in messages], + "max_tokens": 5, + "stream": stream, + "session_id": "overflow", + }, + ) + if stream and timeout == 0: + assert response.status_code == 200 + errors = [ + json.loads(line[6:])["error"] + for line in response.text.splitlines() + if line.startswith('data: {"error"') + ] + assert len(errors) == 1 + error = errors[0] + assert '"finish_reason"' not in response.text + else: + assert response.status_code == 400 + error = response.json()["error"] + assert error["code"] == "context_length_exceeded" + if dialect == "openrouter": + assert error["metadata"] == { + "error_type": "context_length_exceeded", + "provider_code": "context_length_exceeded", + "context_length": 128, + "prompt_tokens": expected, + "completion_tokens": 5, + } + assert "maximum context length is 123 tokens." in error["message"] + assert f"messages resulted in {expected} tokens" in error["message"] + else: + assert "metadata" not in error + assert f"request has {expected} prompt tokens" in error["message"] + response = await client.post( + "/v1/chat/completions", + json={ + "messages": [{"role": "user", "content": "hi"}], + "max_tokens": 1, + "session_id": "overflow", + }, + ) + assert response.status_code == 200 + assert worker.healthy + + asyncio.run(asyncio.wait_for(scenario(), 15)) + + +@pytest.mark.parametrize( + "counts", + [ + None, + {}, + {"prompt_tokens": True, "context_length": 128, "completion_tokens": 1}, + {"prompt_tokens": 200, "context_length": 0, "completion_tokens": 1}, + ], +) +def test_malformed_worker_overflow_counts_are_rejected(counts): + with pytest.raises(WorkerError, match="context overflow counts"): + MultiplexedWorkerClient._validate_message( + { + "request_id": 1, + "error": "overflow", + "code": "context_length_exceeded", + "context_overflow": counts, + } + ) + + +def test_old_worker_overflow_without_counts_does_not_invent_metadata(): + error = ServingChat._generation_error( + WorkerError("too long", code="context_length_exceeded") + ) + assert error.status == 400 + assert "metadata" not in error.body(openrouter=True)["error"] diff --git a/examples/llm_server/python/tool_parsers/hermes.py b/examples/llm_server/python/tool_parsers/hermes.py index 1f65982505f..dbfef9d81af 100644 --- a/examples/llm_server/python/tool_parsers/hermes.py +++ b/examples/llm_server/python/tool_parsers/hermes.py @@ -108,6 +108,10 @@ def _make_item( ) -> ToolCallItem: if not name or name not in tools: raise _UndefinedToolCall(repr(name)) + if isinstance(arguments, str): + arguments = json.loads(arguments or "{}") + if arguments is not None and not isinstance(arguments, dict): + raise ValueError("tool arguments must be an object") item = ToolCallItem( tool_index=self._next_index, name=name, diff --git a/examples/llm_server/python/worker_client.py b/examples/llm_server/python/worker_client.py index 49dc5a68766..812cd2cd177 100644 --- a/examples/llm_server/python/worker_client.py +++ b/examples/llm_server/python/worker_client.py @@ -104,9 +104,12 @@ class WorkerError(RuntimeError): to the right status; None for unstructured failures. """ - def __init__(self, message: str, code: Optional[str] = None): + def __init__( + self, message: str, code: Optional[str] = None, *, context_overflow=None + ): super().__init__(message) self.code = code + self.context_overflow = context_overflow def _decode_worker_json(line: str) -> dict: diff --git a/examples/llm_server/spec/README.md b/examples/llm_server/spec/README.md index 280cacd26b4..a75862c3c66 100644 --- a/examples/llm_server/spec/README.md +++ b/examples/llm_server/spec/README.md @@ -14,6 +14,10 @@ of language and engine. | `GET /health` | implemented | | `POST /v1/completions` | planned | +Each `/v1/models` entry includes `context_length` when the server has a configured +context limit. Clients must use this value or an explicit override for compaction +budgeting; advertising it does not make every client consume it automatically. + ## `POST /v1/chat/completions` OpenAI Chat Completions subset. **Honored** request fields: `model`, `messages`, @@ -26,6 +30,18 @@ uses the worker's unset/random value. `model` must match the id returned by `/v1/models`; unknown ids return `404 model_not_found`. +`messages` must be nonempty and roles must be `system`, `developer`, `user`, +`assistant`, or `tool`. Tool declarations require unique nonempty function names +and object parameter schemas (omitted/null means an empty schema). Historical tool calls require an ID, name, and JSON +argument object (omitted/null/empty arguments mean `{}`). Call IDs must be unique +within an assistant message, but may be reused across turns. Tool results require +a `tool_call_id`; they need not have a matching call in the supplied history. +Compacted histories and repeated tool results are preserved for template rendering. +Malformed structures and boolean/string values for supported numeric generation controls +fail schema validation. Integer controls also reject floats, including integral floats +such as `1024.0`. These failures return HTTP 422 and an `error` object before inference. Unsupported +penalty/logprob controls retain their existing Pydantic coercion before capability checks. + `ignore_eos` is an optional JSON boolean (default `false`) supported by the multiplexed batching worker. It disables model EOS and automatic chat-template stops, allowing generation through terminator tokens up to the requested budget @@ -65,7 +81,8 @@ earlier behavior, which accepted non-boolean values as an opt-out. **Rejected** with `400 invalid_request_error` (`code: "unsupported_parameter"`) rather than silently ignored — a client relying on them would otherwise get -wrong behavior: `n` (> 1), `reasoning_effort`, +wrong behavior: `n` (> 1), `reasoning_effort` and OpenRouter `reasoning` +(except `{}` and `{"exclude": false}`), `frequency_penalty`/`presence_penalty` (nonzero), `logit_bias`, `tool_choice` = `"required"` or a specific-function choice (forcing/restricting a call needs constrained decoding, not implemented), @@ -89,8 +106,64 @@ first chunk carries `delta.role = "assistant"`, subsequent chunks carry `delta.content` (or buffered `delta.tool_calls`), a final chunk carries `finish_reason`, optionally a usage-only chunk (with `stream_options.include_usage`), terminated by `data: [DONE]`. -For models with a reasoning extractor, output is buffered and returned reasoning -is emitted as `delta.reasoning_content` before visible content or tool calls. +For models with an incremental parser, reasoning is emitted as +`delta.reasoning_content` while generation runs. Other reasoning extractors buffer +their output. With tools enabled, content, reasoning, and complete tool calls are +buffered. Idle HTTP streams include SSE +keepalive comments every 15 seconds. Model launchers may bound initial readiness +waiting to return headers during prefill; errors detected after headers arrive +as SSE error events. + +The generic and Muse batching launchers accept `--api-dialect openai|openrouter`. +The `openrouter` dialect attaches requested usage to the finish chunk, once, for +clients that stop consuming on `finish_reason`, and supplies overflow metadata +for context recovery. The default `openai` dialect retains the separate +usage-only chunk described above. + +`--streaming-preflight-timeout` waits for first output, including prefill. The default +`none` preserves an indefinite wait, while finite nonnegative values allow SSE headers +before first output. Once headers are sent, later failures are SSE error events. +This bounds generation-readiness waiting, not waiting for a session transaction +lease or preparing the request before generation starts. + +#### OpenRouter error contract + +The compatibility dialect retains the common `error.message`, `error.type`, and +`error.code` fields. Context overflows detected by Python preflight or by an +updated native worker carry `error.metadata`. Native counts include all prompt +positions, including image positions and numeric BOS. Adapters that cannot count +all positions defer overflow checks to the worker instead of publishing a lower +bound as `prompt_tokens`. Older workers that report +only an unstructured error cannot supply recovery counts. + +The multiplexed error frame uses `code: "context_length_exceeded"` and +`context_overflow: {"prompt_tokens": N, "context_length": C, "completion_tokens": M}`. +Other `invalid_argument` errors are never reclassified as context overflow. + +| Key | Meaning | +| --- | --- | +| `error_type`, `provider_code` | `context_length_exceeded` | +| `context_length` | Full configured model window | +| `prompt_tokens` | Input prompt size | +| `completion_tokens` | Reserved output space: the explicit positive limit, or 1 when unset | + +Let `budget = context_length - completion_tokens`. For a positive budget the +message is exactly: + +```text +This model's maximum context length is {budget} tokens. However, your messages resulted in {prompt_tokens} tokens (the prompt budget reserves {completion_tokens} completion tokens from the {context_length}-token model window). Please reduce the length of the messages or completion. +``` + +The message exposes a usable *input budget* for clients that parse those counts; +metadata preserves the full window. The minimum one-token reservation does not +choose a client compaction margin. If the reservation consumes the whole window, +the ordinary diagnostic message is retained because input compaction cannot make +that reservation fit. + +After streaming headers, this adapter sends a bare `data: {"error": {...}}` event, +then `data: [DONE]`. It does not add a `choices` entry or `finish_reason: "error"`. +This is the adapter's supported error contract in both dialects; the dialect +selects overflow metadata and usage placement, not the full OpenRouter API. ### Tool calling diff --git a/examples/models/muse-glimmer/BATCHING_SERVING.md b/examples/models/muse-glimmer/BATCHING_SERVING.md index 52dfc2969a8..db784b0c852 100644 --- a/examples/models/muse-glimmer/BATCHING_SERVING.md +++ b/examples/models/muse-glimmer/BATCHING_SERVING.md @@ -105,6 +105,39 @@ framing and emits `reasoning_content` and `content` incrementally. Optional ATEM tool parsing is enabled with `--tool-parser atem`; tool-enabled responses currently remain buffered. +The launcher waits for initial generation readiness before returning streaming +headers. Set `--streaming-preflight-timeout 1` to bound generation-readiness +waiting, including prefill, to one second. This does not bound earlier request +preparation or waiting behind another request on the same session. +Idle streams send SSE keepalive comments every 15 +seconds. Admission errors detected before headers retain their HTTP status; late +errors are delivered as SSE errors. Keepalives indicate a live connection, not +measured prefill progress. + +### OpenRouter compatibility + +Start the server with `--api-dialect openrouter` for OpenRouter clients and +`--tool-parser atem` for tool calling. `/v1/models` reports the configured +`context_length`; clients must consume that value or configure their context +budget explicitly. The server does not configure client-side compaction. + +The OpenRouter usage format includes usage once on the terminal finish chunk. +The installed Muse provider stops reading at that chunk, so a later usage-only +chunk would be lost. The default `openai` format keeps the separate usage-only +chunk. Both formats honor `stream_options.include_usage`. +The `openrouter` mode also supplies the error metadata and message counts Muse +needs for reactive context-overflow recovery, including worker-detected overflow +when using an updated native worker. Worker counts include image positions and +numeric BOS. Image prompts bypass Python token-count preflight so only the +worker publishes their image-expanded count; older unstructured worker errors +cannot provide recovery counts. +Counts describe the input prompt and +its available budget after reserving completion space; metadata retains the full +model window. A completion reservation consuming the entire window cannot be +fixed by compacting input: diagnostic metadata remains, but the message does not +match Muse's recoverable prompt-budget format. With no explicit `max_tokens`, +the server reserves one output token; Muse applies its own compaction margin. + Streaming intentionally differs from the legacy buffered fallback for incomplete `<|` headers, which are suppressed at EOF; bare `to=` suffixes remain literal text unless a complete addressed header resolves them. Streaming also removes known diff --git a/examples/models/muse-glimmer/runtime/prompt_preparation.cpp b/examples/models/muse-glimmer/runtime/prompt_preparation.cpp index cf04aef4072..f20711199d0 100644 --- a/examples/models/muse-glimmer/runtime/prompt_preparation.cpp +++ b/examples/models/muse-glimmer/runtime/prompt_preparation.cpp @@ -319,13 +319,9 @@ serving::PromptPreparationResult prepare_muse_glimmer_prompt( const auto cancelled = [&] { return context.cancelled && context.cancelled(); }; - const size_t limit = std::min( - context.max_prompt_positions, - static_cast(spec->max_context_length - 1)); if (cancelled()) return invalid("prompt preparation cancelled"); serving::PromptInput prompt; - size_t known_positions = 0; bool has_image = false; const char* failure = "invalid prompt segment"; const auto append_text = [&](const std::string& text) { @@ -333,8 +329,8 @@ serving::PromptPreparationResult prepare_muse_glimmer_prompt( return !cancelled(); }; const auto append_ids = [&](const nlohmann::json& ids) { - if (!ids.is_array() || ids.size() > limit - known_positions) { - failure = "prompt IDs must be a bounded array"; + if (!ids.is_array()) { + failure = "prompt IDs must be an array"; return false; } std::vector tokens; @@ -346,7 +342,6 @@ serving::PromptPreparationResult prepare_muse_glimmer_prompt( } tokens.push_back(id.get()); } - known_positions += tokens.size(); prompt.segments.emplace_back(std::move(tokens)); return !cancelled(); }; @@ -387,9 +382,8 @@ serving::PromptPreparationResult prepare_muse_glimmer_prompt( } auto computed = muse_glimmer_image_grid( decoded->width, decoded->height, spec->max_soft_tokens); - if (!computed.ok() || - static_cast(computed->soft_tokens) > limit - known_positions) { - failure = "image grid exceeds vision patch or prompt limit"; + if (!computed.ok()) { + failure = "image grid exceeds vision patch limit"; return false; } // The shared Image contract is CHW; the decoder returns interleaved HWC. @@ -400,7 +394,6 @@ serving::PromptPreparationResult prepare_muse_glimmer_prompt( chw[c * pixels + p] = decoded->rgb[p * 3 + c]; prompt.segments.emplace_back( Image(std::move(chw), decoded->width, decoded->height, 3)); - known_positions += static_cast(computed->soft_tokens); has_image = true; return !cancelled(); }; @@ -460,9 +453,12 @@ serving::ModelPreparationResult prepare_muse_glimmer_input( const auto cancelled = [&] { return context.cancelled && context.cancelled(); }; - const size_t limit = std::min( + const size_t context_length = std::min( context.max_prompt_positions, - static_cast(spec->max_context_length - 1)); + static_cast(spec->max_context_length)); + const size_t limit = context_length ? context_length - 1 : 0; + size_t positions = 0; + std::optional first_token; std::vector tokens; MuseGlimmerRGBImage image; MuseGlimmerImageGrid grid; @@ -480,10 +476,14 @@ serving::ModelPreparationResult prepare_muse_glimmer_input( ids = &encoded; } if (ids) { - if (ids->size() > limit - tokens.size() || !valid_tokens(*ids, *spec) || + if (!valid_tokens(*ids, *spec) || std::find(ids->begin(), ids->end(), spec->patch_id) != ids->end()) - return invalid("invalid, oversized, or unbound patch token segment"); - tokens.insert(tokens.end(), ids->begin(), ids->end()); + return invalid("invalid or unbound patch token segment"); + if (!first_token && !ids->empty()) + first_token = ids->front(); + positions += ids->size(); + if (positions <= limit) + tokens.insert(tokens.end(), ids->begin(), ids->end()); } else if (const auto* source = segment.try_get_image()) { if (span.size || !spec->has_vision || !source->is_uint8() || source->channels() != 3 || @@ -495,9 +495,8 @@ serving::ModelPreparationResult prepare_muse_glimmer_input( return invalid("only one bounded uint8 RGB image is supported"); auto computed = muse_glimmer_image_grid( source->width(), source->height(), spec->max_soft_tokens); - if (!computed.ok() || - static_cast(computed->soft_tokens) > limit - tokens.size()) - return invalid("image grid exceeds vision patch or prompt limit"); + if (!computed.ok()) + return invalid("image grid exceeds vision patch limit"); image.width = source->width(); image.height = source->height(); const auto& chw = source->get_uint8_data(); @@ -507,20 +506,28 @@ serving::ModelPreparationResult prepare_muse_glimmer_input( for (size_t c = 0; c < 3; ++c) image.rgb[p * 3 + c] = chw[c * pixels + p]; grid = *computed; - span = {tokens.size(), static_cast(grid.soft_tokens)}; - tokens.insert(tokens.end(), span.size, spec->patch_id); + span = {positions, static_cast(grid.soft_tokens)}; + if (!first_token) + first_token = spec->patch_id; + positions += span.size; + if (positions <= limit) + tokens.insert(tokens.end(), span.size, spec->patch_id); } else { return invalid("unsupported prompt modality"); } } - if (tokens.empty() || tokens.front() != spec->bos_id) { + const bool add_bos = !first_token || *first_token != spec->bos_id; + positions += add_bos; + if (positions > limit) { + return serving::context_length_error(positions, context_length); + } + if (add_bos) { tokens.insert(tokens.begin(), spec->bos_id); if (span.size) ++span.offset; } - if (tokens.size() > limit || !valid_tokens(tokens, *spec) || cancelled()) { - return invalid( - "prompt exceeds context/vocabulary or preparation cancelled"); + if (!valid_tokens(tokens, *spec) || cancelled()) { + return invalid("prompt exceeds vocabulary or preparation cancelled"); } if (!span.size) { return batching::PreparedInputPtr( diff --git a/examples/models/muse-glimmer/serving/serve.py b/examples/models/muse-glimmer/serving/serve.py index 8a4827fbc4c..b3097dc21f2 100644 --- a/examples/models/muse-glimmer/serving/serve.py +++ b/examples/models/muse-glimmer/serving/serve.py @@ -252,12 +252,10 @@ def _with_image(prompt: PromptInput, image_segment: dict | None) -> PromptInput: def _count_prompt_tokens(self, prompt: PromptInput) -> int | None: if prompt.text is not None: return super()._count_prompt_tokens(prompt) - text_segments = [ - segment for segment in prompt.segments or [] if "image" not in segment - ] - if not text_segments: - return self._prompt_token_offset - return super()._count_prompt_tokens(PromptInput(segments=text_segments)) + if any("image" in segment for segment in prompt.segments or []): + # Only native preparation knows the image-expanded decoder positions. + return None + return super()._count_prompt_tokens(prompt) async def _create(self, req: ChatCompletionRequest, lease): # The inherited create() owns the transaction around preparation and @@ -287,9 +285,9 @@ async def _create(self, req: ChatCompletionRequest, lease): if self._max_context: count = self._count_prompt_tokens(prompt_input) if count is not None: - if count >= self._max_context: - raise ContextLengthExceeded(count, self._max_context) requested = req.resolved_max_tokens() + if count >= self._max_context: + raise ContextLengthExceeded(count, self._max_context, requested) if requested > 0 and count + requested > self._max_context: raise ContextLengthExceeded(count, self._max_context, requested) diff --git a/examples/models/muse-glimmer/serving/serve_batching.py b/examples/models/muse-glimmer/serving/serve_batching.py index 1eea2213504..b96061a98ef 100644 --- a/examples/models/muse-glimmer/serving/serve_batching.py +++ b/examples/models/muse-glimmer/serving/serve_batching.py @@ -24,7 +24,11 @@ from executorch.examples.llm_server.python.multiplexed_worker_client import ( spawn_multiplexed_worker, ) -from executorch.examples.llm_server.python.serve import _validate_limits + +from executorch.examples.llm_server.python.serve import ( + _parse_preflight_timeout, + _validate_limits, +) from executorch.examples.llm_server.python.server import build_app from executorch.examples.llm_server.python.session_runtime import SessionRuntime from executorch.examples.models.muse_glimmer.serving import serve as legacy @@ -90,6 +94,21 @@ def _parse_args(argv=None): parser.add_argument("--bos-id", type=int, default=200000) parser.add_argument("--eos-id", type=int, default=200001) parser.add_argument("--tool-parser", choices=("atem", "none"), default="none") + parser.add_argument( + "--streaming-preflight-timeout", + type=_parse_preflight_timeout, + default=None, + help="Seconds to wait for first output (including prefill); none waits indefinitely. " + "Later errors are SSE events.", + ) + parser.add_argument( + "--api-dialect", + choices=("openai", "openrouter"), + default="openai", + help="OpenRouter clients require usage on the finish chunk; OpenAI uses " + "a separate usage-only chunk. OpenRouter also selects " + "context-overflow error metadata.", + ) parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--port", type=int, default=8000) args = parser.parse_args(argv) @@ -205,6 +224,8 @@ async def serving_factory(): template, args.model_id, max_context=args.max_context, + streaming_preflight_timeout=args.streaming_preflight_timeout, + api_dialect=args.api_dialect, max_image_bytes=args.max_image_bytes, tool_detector_cls=legacy._tool_detector(args.tool_parser), prompt_token_offset=1, diff --git a/examples/models/muse-glimmer/tests/batching_runtime_test.cpp b/examples/models/muse-glimmer/tests/batching_runtime_test.cpp index 39eccb96a4f..ba3c8b69059 100644 --- a/examples/models/muse-glimmer/tests/batching_runtime_test.cpp +++ b/examples/models/muse-glimmer/tests/batching_runtime_test.cpp @@ -275,10 +275,22 @@ void preparation() { image_tail->prefix_identity()->spans.back()), "image tail must remain an opaque position"); context.max_prompt_positions = 3; + const auto overflow = std::get( + prepare({{"prompt", "ab"}, {"image", image}}, context, identity)); require( - std::holds_alternative(prepare( - {{"prompt", "ab"}, {"image", image}}, context, identity)), - "expanded limit ignored"); + overflow.code == serving::ErrorCode::ContextLengthExceeded && + overflow.context_overflow && + overflow.context_overflow->prompt_tokens == 4 && + overflow.context_overflow->context_length == 3, + "image overflow lost exact BOS/image/text-tail counts"); + const auto token_overflow = std::get(prepare( + {{"prompt_segments", {{{"ids", {200000, 1, 2, 3}}}, {{"text", "tail"}}}}}, + context, + identity)); + require( + token_overflow.context_overflow && + token_overflow.context_overflow->prompt_tokens == 8, + "oversized ID prefix lost its BOS or trailing text count"); context.max_prompt_positions = 99; image["data"] = "not-base64"; require( diff --git a/examples/models/muse-glimmer/tests/test_serve.py b/examples/models/muse-glimmer/tests/test_serve.py index 4534f73a7d8..5241a04cc10 100644 --- a/examples/models/muse-glimmer/tests/test_serve.py +++ b/examples/models/muse-glimmer/tests/test_serve.py @@ -439,7 +439,7 @@ def test_muse_glimmer_serving_injects_image_segment_into_runtime(stream): assert len(request.messages[0].content) == 3 -def test_muse_glimmer_prompt_count_ignores_image_segment(): +def test_muse_glimmer_prompt_count_defers_images_to_native_worker(): serving = serve.MuseGlimmerServingChat(object(), _ImageTemplate(), "muse_glimmer") prompt = PromptInput( segments=[ @@ -449,7 +449,7 @@ def test_muse_glimmer_prompt_count_ignores_image_segment(): ] ) - assert serving._count_prompt_tokens(prompt) == 5 + assert serving._count_prompt_tokens(prompt) is None def test_strip_muse_glimmer_header_returns_visible_answer(): @@ -1084,3 +1084,69 @@ def test_rejects_multiple_runners(monkeypatch): ) with pytest.raises(SystemExit): serve.main() + + +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize("dialect", ["openai", "openrouter"]) +@pytest.mark.parametrize("text_tokens,completion", [(10, 1), (6, 2)]) +def test_image_overflow_uses_worker_counts(stream, dialect, text_tokens, completion): + from executorch.examples.llm_server.python.server import build_app + from executorch.examples.llm_server.python.worker_client import WorkerError + from fastapi.testclient import TestClient + + image_positions = 16 + expected = text_tokens + 1 + image_positions + + class Template(_ImageTemplate): + def render(self, messages, *, tools, template_kwargs): + return "text " * text_tokens + serve._IMAGE_PLACEHOLDER + + class Runtime(_ImageRuntime): + async def generate_stream(self, session_id, prompt, options, stats): + self.prompt = prompt + raise WorkerError( + "image-expanded prompt exceeds context", + code="context_length_exceeded", + context_overflow={ + "prompt_tokens": expected, + "context_length": 8, + "completion_tokens": completion, + }, + ) + yield + + runtime = Runtime() + serving = serve.MuseGlimmerServingChat( + runtime, + Template(), + "muse_glimmer", + max_context=8, + prompt_token_offset=1, + api_dialect=dialect, + ) + body = _image_request("data:image/png;base64,eA==").model_dump(exclude_none=True) + body.update(stream=stream, max_tokens=completion) + response = TestClient(build_app(serving, "muse_glimmer")).post( + "/v1/chat/completions", json=body + ) + assert runtime.prompt is not None + assert any("image" in segment for segment in runtime.prompt.segments) + if stream: + assert response.status_code == 200 + errors = [ + json.loads(line[6:])["error"] + for line in response.text.splitlines() + if line.startswith('data: {"error"') + ] + assert len(errors) == 1 + error = errors[0] + else: + assert response.status_code == 400 + error = response.json()["error"] + assert error["code"] == "context_length_exceeded" + assert str(expected) in error["message"] + if dialect == "openrouter": + assert error["metadata"]["prompt_tokens"] == expected + assert error["metadata"]["completion_tokens"] == completion + else: + assert "metadata" not in error diff --git a/examples/models/muse-glimmer/tests/test_serve_batching.py b/examples/models/muse-glimmer/tests/test_serve_batching.py index 8879eb3900b..cbad169e2ae 100644 --- a/examples/models/muse-glimmer/tests/test_serve_batching.py +++ b/examples/models/muse-glimmer/tests/test_serve_batching.py @@ -190,7 +190,15 @@ def test_image_frame_capacity(arguments, shortfall): @pytest.mark.parametrize("constructor_failure", [False, True]) -def test_chat_factory_closes_worker(arguments, monkeypatch, constructor_failure): +@pytest.mark.parametrize("bounded", [False, True]) +def test_chat_factory_closes_worker( + arguments, monkeypatch, constructor_failure, bounded +): + arguments = arguments + ( + ["--api-dialect", "openrouter", "--streaming-preflight-timeout", "0.25"] + if bounded + else [] + ) captured = {} class Worker: @@ -235,6 +243,8 @@ async def spawn(command, **kwargs): async def scenario(): async with factory() as chat: assert isinstance(chat, serve.MuseGlimmerServingChat) + assert chat.api_dialect == ("openrouter" if bounded else "openai") + assert chat._streaming_preflight_timeout == (0.25 if bounded else None) assert chat._prompt_token_offset == 1 assert chat._max_image_bytes == args.max_image_bytes assert not worker.closed @@ -246,3 +256,19 @@ async def scenario(): asyncio.run(scenario()) assert worker.closed assert captured["command"] == serve_batching._worker_command(args) + + +@pytest.mark.parametrize("value,expected", [("none", None), ("0", 0.0), ("0.25", 0.25)]) +def test_api_dialect_and_preflight_options(arguments, value, expected): + args = serve_batching._parse_args( + arguments + + ["--api-dialect", "openrouter", "--streaming-preflight-timeout", value] + ) + assert args.api_dialect == "openrouter" + assert args.streaming_preflight_timeout == expected + + +@pytest.mark.parametrize("value", ["-1", "inf", "nan", "invalid"]) +def test_invalid_preflight_option(arguments, value): + with pytest.raises(SystemExit): + serve_batching._parse_args(arguments + ["--streaming-preflight-timeout", value]) diff --git a/extension/llm/serving/detail/prompt_preparer.h b/extension/llm/serving/detail/prompt_preparer.h index d380a4a09ea..d0d31a18301 100644 --- a/extension/llm/serving/detail/prompt_preparer.h +++ b/extension/llm/serving/detail/prompt_preparer.h @@ -25,11 +25,13 @@ namespace executorch::extension::llm::serving::detail { inline runtime::Result> prepare_text_prompt( const tokenizers::Tokenizer& tokenizer, const PromptInput& input, - std::size_t max_tokens = std::numeric_limits::max()) { + std::size_t max_tokens = std::numeric_limits::max(), + std::size_t* num_tokens = nullptr) { const auto limit = std::min( max_tokens, static_cast(std::numeric_limits::max())); std::vector prepared; + std::size_t total = 0; for (const auto& segment : input.segments) { std::vector encoded; const std::vector* ids; @@ -45,12 +47,19 @@ inline runtime::Result> prepare_text_prompt( } else { return runtime::Error::InvalidArgument; } - if (ids->size() > limit - prepared.size()) { + if (ids->size() > std::numeric_limits::max() - total) { return runtime::Error::InvalidArgument; } - prepared.insert(prepared.end(), ids->begin(), ids->end()); + total += ids->size(); + // Count all segments on overflow without retaining the oversized prompt. + if (total <= limit) { + prepared.insert(prepared.end(), ids->begin(), ids->end()); + } + } + if (num_tokens) { + *num_tokens = total; } - if (prepared.empty()) { + if (total == 0 || total > limit) { return runtime::Error::InvalidArgument; } return prepared; @@ -136,8 +145,13 @@ inline PreparedPromptResult prepare_prompt( if (model_preparer) { result = model_preparer(context, std::move(input)); } else { - auto tokens = prepare_text_prompt(context.tokenizer, input, limit); + std::size_t num_tokens = 0; + auto tokens = + prepare_text_prompt(context.tokenizer, input, limit, &num_tokens); if (!tokens.ok()) { + if (num_tokens > limit) { + return context_length_error(num_tokens, limit); + } return ServingError{ ErrorCode::InvalidArgument, "prompt preparation failed"}; } @@ -152,10 +166,13 @@ inline PreparedPromptResult prepare_prompt( } const auto size = prepared ? prepared->size() : 0; const auto identity = prepared ? prepared->prefix_identity() : nullptr; - if (!size || size > limit || (identity && identity->size() != size)) { + if (!size || (identity && identity->size() != size)) { return ServingError{ ErrorCode::InvalidArgument, "invalid prepared prompt size or identity"}; } + if (size > limit) { + return context_length_error(size, limit); + } return prepared; } diff --git a/extension/llm/serving/serving_runtime.cpp b/extension/llm/serving/serving_runtime.cpp index be98d5b9366..17e2a974921 100644 --- a/extension/llm/serving/serving_runtime.cpp +++ b/extension/llm/serving/serving_runtime.cpp @@ -868,6 +868,10 @@ struct ServingRuntime::Impl { return std::nullopt; } if (auto* error = std::get_if(&prepared)) { + if (error->context_overflow) { + error->context_overflow->completion_tokens = + options.max_new_tokens.value_or(1); + } return std::move(*error); } text.prepared = std::get(std::move(prepared)); @@ -882,7 +886,13 @@ struct ServingRuntime::Impl { ? static_cast( std::min(available, static_cast(INT32_MAX))) : config_.default_max_new_tokens); - if (wanted <= 0 || available == 0) { + if (available == 0) { + return context_length_error( + text.terminal.stats.prompt_tokens, + context_limit, + options.max_new_tokens.value_or(1)); + } + if (wanted <= 0) { return ServingError{ ErrorCode::InvalidArgument, "no generation budget available"}; } diff --git a/extension/llm/serving/test/prompt_preparer_test.cpp b/extension/llm/serving/test/prompt_preparer_test.cpp index 9b4994a7291..88f33bd6ece 100644 --- a/extension/llm/serving/test/prompt_preparer_test.cpp +++ b/extension/llm/serving/test/prompt_preparer_test.cpp @@ -174,8 +174,7 @@ TEST(PromptPreparerTest, NormalizesAndValidatesDirectAndDeferredRawPrompts) { {{{make_text_input("a"), make_token_input({0})}}, {1, 2, 0}}, {{}, {}}, {{{make_text_input("missing")}}, {}}, - {{{make_image_input(Image{})}}, {}}, - {{{make_text_input("a"), make_token_input({3, 4})}}, {}}}) { + {{{make_image_input(Image{})}}, {}}}) { SCOPED_TRACE(deferred); PromptInput input = raw; std::optional prepare; @@ -289,10 +288,7 @@ TEST(PromptPreparerTest, RejectsInvalidDirectAndDeferredOpaquePrompts) { {nullptr, 3}, {invalid, 3}, {std::make_shared(0), 3}, - {std::make_shared(4), 3}, - {std::make_shared(1), 0}, - {std::make_shared(beyond_position_limit), - beyond_position_limit}}) { + {std::make_shared(0), beyond_position_limit}}) { SCOPED_TRACE(deferred); const PromptPreparationContext context{tokenizer, limit, {}}; PromptInput input; @@ -316,6 +312,40 @@ TEST(PromptPreparerTest, RejectsInvalidDirectAndDeferredOpaquePrompts) { EXPECT_TRUE(tokenizer.calls.empty()); } +TEST(PromptPreparerTest, OverflowCountsAllSegmentsAndOpaquePositions) { + RecordingTokenizer tokenizer; + tokenizer.encodings = {{"tail", {4, 5}}}; + const PromptPreparationContext context{tokenizer, 2, {}}; + PromptInput input{{make_token_input({1, 2, 3}), make_text_input("tail")}}; + std::optional prepare; + auto result = serving::detail::prepare_prompt(context, input, prepare); + auto error = std::get(result); + EXPECT_EQ(error.code, ErrorCode::ContextLengthExceeded); + ASSERT_TRUE(error.context_overflow); + EXPECT_EQ(error.context_overflow->prompt_tokens, 5u); + EXPECT_EQ(error.context_overflow->context_length, 2u); + const auto position_limit = + static_cast(std::numeric_limits::max()); + for (auto [size, limit] : std::vector>{ + {7, 2}, {1, 0}, {position_limit + 1, position_limit + 1}}) { + ModelPreparer model = [size = size]( + const auto&, + const auto&) -> serving::ModelPreparationResult { + return batching::PreparedInputPtr( + std::make_shared(size)); + }; + result = serving::detail::prepare_prompt( + PromptPreparationContext{tokenizer, limit, {}}, input, prepare, model); + error = std::get(result); + EXPECT_EQ(error.code, ErrorCode::ContextLengthExceeded); + ASSERT_TRUE(error.context_overflow); + EXPECT_EQ(error.context_overflow->prompt_tokens, size); + EXPECT_EQ( + error.context_overflow->context_length, + std::min(limit, position_limit)); + } +} + TEST(PromptPreparerTest, RejectsEmptyCallbackAndPreservesCallbackError) { RecordingTokenizer tokenizer; const PromptPreparationContext context{tokenizer, 3, {}}; diff --git a/extension/llm/serving/test/text_generation_test.cpp b/extension/llm/serving/test/text_generation_test.cpp index b83440e1632..e797fd77fff 100644 --- a/extension/llm/serving/test/text_generation_test.cpp +++ b/extension/llm/serving/test/text_generation_test.cpp @@ -959,7 +959,10 @@ TEST_F(TextGenerationTest, OpaqueMetadataAndOptionsPreserveRawHistory) { const auto handle = submit(event, std::move(prompt), options); handle.wait(); ASSERT_TRUE(handle.error()); - EXPECT_EQ(handle.error()->code, ErrorCode::InvalidArgument); + EXPECT_EQ( + handle.error()->code, + i >= 2 && i <= 4 ? ErrorCode::ContextLengthExceeded + : ErrorCode::InvalidArgument); ASSERT_TRUE(event->terminal); EXPECT_EQ(event->terminal->finish_reason, FinishReason::Failed); EXPECT_EQ(event->terminals, 1u); @@ -2143,7 +2146,10 @@ TEST_F(TextGenerationTest, UnsetAndExplicitBudgetsUseRemainingContext) { auto full = output(); submit(full, ids({1, 2, 3, 4, 5})).wait(); ASSERT_TRUE(full->terminal); - EXPECT_EQ(full->terminal->error->code, ErrorCode::InvalidArgument); + EXPECT_EQ(full->terminal->error->code, ErrorCode::ContextLengthExceeded); + ASSERT_TRUE(full->terminal->error->context_overflow); + EXPECT_EQ(full->terminal->error->context_overflow->prompt_tokens, 5u); + EXPECT_EQ(full->terminal->error->context_overflow->context_length, 5u); } TEST_F(TextGenerationTest, UnknownContextUsesServiceDefaultOnlyForUnsetLimit) { @@ -2162,6 +2168,35 @@ TEST_F(TextGenerationTest, UnknownContextUsesServiceDefaultOnlyForUnsetLimit) { EXPECT_GE(event->terminal->stats.total_ms, event->terminal->stats.prefill_ms); } +TEST_F(TextGenerationTest, InvalidDefaultBudgetIsNotContextOverflow) { + for (const auto default_budget : {0, -1}) { + config.max_context_length = 0; + config.default_max_new_tokens = default_budget; + start(); + auto invalid = output(); + auto result = runtime->generate( + std::nullopt, ids({10, 11}), {}, [invalid](GenerationEvent update) { + invalid->accept(std::move(update)); + }); + ASSERT_TRUE(std::holds_alternative(result)); + std::get(result).wait(); + ASSERT_TRUE(invalid->terminal); + ASSERT_TRUE(invalid->terminal->error); + EXPECT_EQ(invalid->terminal->error->code, ErrorCode::InvalidArgument); + EXPECT_FALSE(invalid->terminal->error->context_overflow); + + GenerationOptions options; + options.max_new_tokens = 1; + auto valid = output(); + submit(valid, ids({10, 11}), options, std::nullopt).wait(); + ASSERT_TRUE(valid->terminal); + EXPECT_FALSE(valid->terminal->error); + EXPECT_EQ(valid->terminal->stats.completion_tokens, 1u); + EXPECT_EQ(runtime->info().active_sessions, 0u); + runtime.reset(); + } +} + TEST_F(TextGenerationTest, StreamingStringStopIgnoresLaterRawTokens) { tokenizer.pieces = {{100, "hello EN"}, {101, "Dhidden"}, {102, "ignored"}}; executor.burst = 3; diff --git a/extension/llm/serving/types.h b/extension/llm/serving/types.h index 64949b4d7ba..165dd9ac98a 100644 --- a/extension/llm/serving/types.h +++ b/extension/llm/serving/types.h @@ -76,13 +76,31 @@ enum class ET_EXPERIMENTAL ErrorCode { SessionBusy, CapacityExceeded, Internal, + ContextLengthExceeded, +}; + +struct ET_EXPERIMENTAL ContextOverflow { + std::size_t prompt_tokens; + std::size_t context_length; + std::size_t completion_tokens = 1; }; struct ET_EXPERIMENTAL ServingError { ErrorCode code = ErrorCode::Internal; std::string message; + std::optional context_overflow; }; +inline ServingError context_length_error( + std::size_t prompt_tokens, + std::size_t context_length, + std::size_t completion_tokens = 1) { + return { + ErrorCode::ContextLengthExceeded, + "context length exceeded", + ContextOverflow{prompt_tokens, context_length, completion_tokens}}; +} + // Valid only during preparation on the runtime's control thread. The callback // must do bounded work and may poll cancelled while preparing a large prompt. struct ET_EXPERIMENTAL PromptPreparationContext { From 1f00a23fc99477ae4df1819250811d42ab2b17bf Mon Sep 17 00:00:00 2001 From: Mergen Nachin Date: Thu, 5 Nov 2026 15:12:09 -0500 Subject: [PATCH 105/128] Bind cached assistant turns to history and recover idle session capacity (#24615) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After history edits or compaction, an identical visible reply could restore generated tokens from the wrong preceding history. Bind replay records to the preceding message history, including source image identity and supplied reasoning. Compute fingerprints in one pass, ignore client-only cache-control markers, and treat equivalent string/single-text-part content identically. Opt-in idle-session eviction recovers logical session-slot capacity while protecting active, queued, and settling transactions. Only a session-slot refusal triggers eviction, with at most one generation retry before raw output. Anonymous requests may also evict named idle sessions under this policy and release their temporary slot on completion. Request, operation-queue, executor, and output-buffer overload never trigger eviction. Clients resend full history after eviction. Both workers retain idempotent close and report `existed` in the acknowledgement. The runtime exposes a close result with existence information instead of a boolean that changes absent-close success into failure. Python skips stale candidates until a real slot is released. Known preadmission refusals carry `admitted:false`; a locally rejected close preserves LRU order. Actual close failures retain tracking and rotate the candidate. Older multiplexed workers that report only generic capacity errors do not trigger automatic eviction. Streaming and non-streaming requests share admission tracking on the first raw output or successful terminal response. Ambiguous failures before that point remain tracked for cleanup; a settling runtime lease defers the close. Recovery checks actual task cancellation before eviction and replacement, including cancellation swallowed while generation cleanup finishes. Native regressions run through ServingChat, SessionRuntime, MultiplexedWorkerClient, and the C++ worker: named/anonymous admission, stale `existed:false` candidates, close and generation retry, before/after-header recovery, temporary-slot release, and local close overload. A separate native protocol test covers synchronous runtime rejection carrying `admitted:false`. Decode-error tests verify the same admission retention in both response modes. Validation of the revised stack: 1,535 Python serving, ATEM, migration, and client-contract tests passed with both native test workers enabled; three Qwen-tokenizer tests were skipped and Qwen/Gemma tests were excluded. All ten native serving/batching/worker/Muse suites passed. Applicable lintrunner checks passed; the Git hook's pinned libcst bootstrap cannot install under Python 3.13, so those checks ran directly with the installed tools. Fresh live HTTP validation passed 32/32 targeted scenarios in each API dialect, four worker-originated overflow cases, and anonymous eviction after filling all eight named slots. The isolated test launcher bypassed Python token-count preflight to force the real native overflow path: the worker reported 9,056 prompt tokens, exactly matching 9,055 Hugging Face tokens plus BOS, with a context window of 8,192 and completion reservation of 30. It returned HTTP 400 in both response modes, OpenRouter recovery metadata, and the original default OpenAI wording. Native tests additionally cover errors after streaming headers and image-expanded counts; live vision inference was not exercised because the available artifact is text-only. Live configuration: Q4_K_XL/BF16 off-graph MLX, eight sessions, ATEM, eight prefix-cache entries, eviction enabled, DFlash disabled, Xcode 27A9275 and the existing Metal JIT-cache workaround. Tests used an isolated port; the existing server remained available. This is functional validation, not the Q4_K_M+DFlash performance comparison. Stack / merge order: #24614 → #24615 → #24616 → #24683. Authored with AI assistance using OpenAI Codex. The latest restack uses #24614's shared active-peer fixture in the native session-recovery tests. The combined workflow passes 1,604 Python tests, with all 64 native-backed cases executed and three unrelated Qwen-tokenizer skips. Non-native tests run before the build; native tests run afterward with both executable paths required. All ten C++ suites and the CTest Python entry pass. --- .../llm_server/cpp/multiplexed_worker.cpp | 45 +- .../cpp/test/test_multiplexed_worker.cpp | 40 +- examples/llm_server/cpp/test_worker_loop.cpp | 14 + examples/llm_server/cpp/worker_loop.h | 10 +- examples/llm_server/python/errors.py | 8 +- .../python/multiplexed_worker_client.py | 18 +- .../llm_server/python/openai_transcript.py | 57 +- examples/llm_server/python/serving_chat.py | 279 ++++++- examples/llm_server/python/session_runtime.py | 26 +- examples/llm_server/python/tests/conftest.py | 2 + .../python/tests/test_concurrent_serving.py | 2 + .../tests/test_multiplexed_worker_client.py | 35 +- .../test_multiplexed_worker_client_native.py | 11 + .../tests/test_native_session_eviction.py | 127 ++++ .../python/tests/test_openrouter_recovery.py | 138 +++- .../python/tests/test_session_eviction.py | 694 ++++++++++++++++++ .../llm_server/python/tests/test_sessions.py | 34 + .../python/tests/test_warm_resume_scaffold.py | 27 +- .../python/tests/test_worker_client.py | 8 +- examples/llm_server/python/worker_client.py | 35 +- examples/llm_server/spec/README.md | 44 +- .../models/muse-glimmer/BATCHING_SERVING.md | 19 +- examples/models/muse-glimmer/serving/serve.py | 6 +- .../muse-glimmer/serving/serve_batching.py | 7 + .../models/muse-glimmer/tests/test_serve.py | 72 +- extension/llm/serving/serving_runtime.cpp | 48 +- extension/llm/serving/serving_runtime.h | 11 + .../serving/test/generation_bridge_test.cpp | 6 +- .../llm/serving/test/serving_runtime_test.cpp | 26 +- extension/llm/serving/types.h | 1 + 30 files changed, 1734 insertions(+), 116 deletions(-) create mode 100644 examples/llm_server/python/tests/test_native_session_eviction.py create mode 100644 examples/llm_server/python/tests/test_session_eviction.py diff --git a/examples/llm_server/cpp/multiplexed_worker.cpp b/examples/llm_server/cpp/multiplexed_worker.cpp index ba3e4358940..97110a40cd2 100644 --- a/examples/llm_server/cpp/multiplexed_worker.cpp +++ b/examples/llm_server/cpp/multiplexed_worker.cpp @@ -47,6 +47,8 @@ const char* error_code(serving::ErrorCode code) { return "session_busy"; case serving::ErrorCode::CapacityExceeded: return "capacity_exhausted"; + case serving::ErrorCode::SessionCapacityExceeded: + return "session_capacity_exhausted"; case serving::ErrorCode::Internal: return "internal"; case serving::ErrorCode::ContextLengthExceeded: @@ -420,10 +422,10 @@ class Worker { try { if (name == "overloaded") { std::lock_guard lock(mutex_); - complete( - op, - failure( - "capacity_exhausted", "worker operation capacity exhausted")); + auto response = failure( + "capacity_exhausted", "worker operation capacity exhausted"); + response["admitted"] = false; + complete(op, std::move(response)); } else if (name == "generate") { fields( message, @@ -490,7 +492,9 @@ class Worker { { std::lock_guard lock(mutex_); if (const auto* error = std::get_if(&result)) { - complete(op, failure(*error)); + auto response = failure(*error); + response["admitted"] = false; + complete(op, std::move(response)); } else { op->handle = std::get(std::move(result)); if (op->cancelled) @@ -523,14 +527,18 @@ class Worker { : name == "close" ? "closed" : "reset"; auto completion = [this, weak = std::weak_ptr(op), ack]( - serving::LifecycleResult error) { + serving::LifecycleResult error, + std::optional existed) { try { if (auto active = weak.lock()) { { std::lock_guard lock(mutex_); if (failed_) return; - complete(active, error ? failure(*error) : Json{{ack, true}}); + auto response = error ? failure(*error) : Json{{ack, true}}; + if (!error && existed.has_value()) + response["existed"] = *existed; + complete(active, std::move(response)); } checkpoint(Checkpoint::TerminalEnqueued, active->id); } @@ -539,12 +547,23 @@ class Worker { changed_.notify_all(); } }; - if (name == "open") - runtime_.open_session_async(std::move(key), std::move(completion)); - else if (name == "close") - runtime_.close_session_async(std::move(key), std::move(completion)); - else - runtime_.reset_session_async(std::move(key), std::move(completion)); + if (name == "close") { + runtime_.close_session_with_result_async( + std::move(key), + [completion = + std::move(completion)](serving::CloseResult result) { + completion(std::move(result.error), result.existed); + }); + } else { + auto lifecycle = [completion = std::move(completion)]( + serving::LifecycleResult error) { + completion(std::move(error), std::nullopt); + }; + if (name == "open") + runtime_.open_session_async(std::move(key), std::move(lifecycle)); + else + runtime_.reset_session_async(std::move(key), std::move(lifecycle)); + } } else { throw std::invalid_argument("unsupported or missing op"); } diff --git a/examples/llm_server/cpp/test/test_multiplexed_worker.cpp b/examples/llm_server/cpp/test/test_multiplexed_worker.cpp index 2ad7e12e22f..54fe20a7710 100644 --- a/examples/llm_server/cpp/test/test_multiplexed_worker.cpp +++ b/examples/llm_server/cpp/test/test_multiplexed_worker.cpp @@ -545,7 +545,7 @@ TEST_F(ProtocolTest, AdmissionFailureDoesNotPoisonOtherRequests) { send(generate(2)); auto error = receive(); EXPECT_EQ(error.at("request_id"), 2); - EXPECT_EQ(error.at("code"), "capacity_exhausted"); + EXPECT_EQ(error.at("code"), "session_capacity_exhausted"); send({{"op", "reset"}, {"request_id", 3}, {"session_id", "owned"}}); EXPECT_TRUE(receive().value("reset", false)); auto request = generate(4); @@ -1314,6 +1314,40 @@ TEST_F(ProtocolTest, TerminalNewlineRetriesKeepAdmissionReserved) { EXPECT_EQ(finish(), 0); } +TEST_F(ProtocolTest, IdempotentCloseReportsWhetherSessionExisted) { + start(); + send({{"op", "open"}, {"request_id", 1}, {"session_id", "s"}}); + ASSERT_TRUE(receive().at("opened")); + for (uint64_t id = 2; id <= 3; ++id) { + send({{"op", "close"}, {"request_id", id}, {"session_id", "s"}}); + auto response = receive(); + EXPECT_TRUE(response.at("closed")); + EXPECT_EQ(response.at("existed"), id == 2); + } + EXPECT_EQ(finish(), 0); +} + +TEST_F(ProtocolTest, RuntimeAdmissionRejectionReportsNotAdmitted) { + // Keep transport capacity available while filling the runtime request limit. + start({}, 4, 1); + executor_.hold(); + send(generate(1, 1)); + ASSERT_TRUE(wait_execute()); + send(generate(2, 1)); + auto refused = receive(); + EXPECT_EQ(refused.at("request_id"), 2); + EXPECT_EQ(refused.at("code"), "capacity_exhausted"); + EXPECT_EQ(refused.at("admitted"), false); + EXPECT_NE(refused.at("error"), "worker operation capacity exhausted"); + executor_.release(); + EXPECT_EQ(receive().at("token"), tokenizer_.piece); + EXPECT_TRUE(receive().value("done", false)); + send(generate(3, 1)); + EXPECT_EQ(receive().at("token"), tokenizer_.piece); + EXPECT_TRUE(receive().value("done", false)); + EXPECT_EQ(finish(), 0); +} + TEST_F(ProtocolTest, OperationCapacityReservesCancellationResponses) { MultiplexedWorkerConfig config; config.max_inflight_requests = 1; @@ -1322,7 +1356,9 @@ TEST_F(ProtocolTest, OperationCapacityReservesCancellationResponses) { send(generate(1, 100)); ASSERT_TRUE(wait_execute()); send(generate(2)); - EXPECT_EQ(receive().at("code"), "capacity_exhausted"); + auto refused = receive(); + EXPECT_EQ(refused.at("code"), "capacity_exhausted"); + EXPECT_EQ(refused.at("admitted"), false); send({{"op", "cancel"}, {"request_id", 3}, {"target_request_id", 1}}); EXPECT_TRUE(receive().value("cancelled", false)); executor_.release(); diff --git a/examples/llm_server/cpp/test_worker_loop.cpp b/examples/llm_server/cpp/test_worker_loop.cpp index 692d060f3b3..a55d06a7d03 100644 --- a/examples/llm_server/cpp/test_worker_loop.cpp +++ b/examples/llm_server/cpp/test_worker_loop.cpp @@ -1008,6 +1008,19 @@ void test_additional_terminal_stats_reject_reserved_key() { check("terminal stats: no partial terminal event", em.done.is_null()); } +void test_close_named_reports_existence() { + FakeEngine engine; + WorkerSessions sessions(engine); + std::string code; + check("close_named: missing is idempotent", !sessions.close_named("s")); + check( + "close_named: open succeeds", sessions.open_named("s", code) != nullptr); + check("close_named: releases existing slot", sessions.close_named("s")); + check( + "close_named: repeated close reports absence", + !sessions.close_named("s")); +} + void test_reset_named_only_clears_on_success() { FakeEngine engine; WorkerSessions sessions(engine); @@ -1076,6 +1089,7 @@ int main() { test_additional_terminal_stats(); test_additional_terminal_stats_reject_reserved_key(); test_reset_named_only_clears_on_success(); + test_close_named_reports_existence(); printf( "\n%s (%d failure(s))\n", g_failures ? "FAILURES" : "ALL PASS", diff --git a/examples/llm_server/cpp/worker_loop.h b/examples/llm_server/cpp/worker_loop.h index a4468a25167..8c89b3b6429 100644 --- a/examples/llm_server/cpp/worker_loop.h +++ b/examples/llm_server/cpp/worker_loop.h @@ -53,6 +53,7 @@ // resumable // ...optional model-specific terminal stats} // open/close/reset: {"opened"|"closed"|"reset": true, "session_id": str} +// Close also includes "existed": bool (false for an already-absent key). // error: {"error": str, "code"?: str} // capacity_exhausted | // // unsupported_session @@ -884,8 +885,8 @@ class WorkerSessions { } // Destroy a named session (freeing its per-session state); idempotent. - void close_named(const std::string& id) { - named_.erase(id); + bool close_named(const std::string& id) { + return named_.erase(id) != 0; } // Clear a named session's context (reset KV/recurrent + resident ids) while @@ -964,8 +965,9 @@ inline int run_worker_stdio_loop( throw std::runtime_error("session_id required for op"); } if (op == "close") { - sessions.close_named(id); - worker_emit({{"closed", true}, {"session_id", id}}); + const bool existed = sessions.close_named(id); + worker_emit( + {{"closed", true}, {"session_id", id}, {"existed", existed}}); } else if (op == "reset") { // idempotent (no-op if absent); only acks success if the reset took if (sessions.reset_named(id) != ::executorch::runtime::Error::Ok) { diff --git a/examples/llm_server/python/errors.py b/examples/llm_server/python/errors.py index 016d64e7b55..0aff43e7114 100644 --- a/examples/llm_server/python/errors.py +++ b/examples/llm_server/python/errors.py @@ -115,6 +115,7 @@ class SessionCapacity(APIError): (capacity_exhausted -> 429).""" def __init__(self, code: str): + self.session_slots_exhausted = code == "session_capacity_exhausted" if code == "unsupported_session": super().__init__( status=400, @@ -125,8 +126,11 @@ def __init__(self, code: str): else: super().__init__( status=429, - message="Session capacity exhausted; reuse an existing session_id " - "or retry later.", + message=( + "Session capacity exhausted; reuse an existing session_id or retry later." + if self.session_slots_exhausted + else "Request capacity exhausted; retry later." + ), err_type="capacity_error", code="capacity_exhausted", ) diff --git a/examples/llm_server/python/multiplexed_worker_client.py b/examples/llm_server/python/multiplexed_worker_client.py index f839c306cf6..f349f43934c 100644 --- a/examples/llm_server/python/multiplexed_worker_client.py +++ b/examples/llm_server/python/multiplexed_worker_client.py @@ -343,7 +343,9 @@ def _reserve(self, op="generate", ack="done"): self._ensure_usable() if len(self._requests) >= self.max_inflight_requests: raise WorkerError( - "worker request capacity exhausted", code="capacity_exhausted" + "worker request capacity exhausted", + code="capacity_exhausted", + admitted=False, ) state = _Request(self._allocate_id(), op, self._loop.create_future(), ack) self._requests[state.request_id] = state @@ -584,6 +586,9 @@ def _validate_message(msg): or counts["context_length"] == 0 ): raise WorkerError("invalid worker context overflow counts") + for flag, expected_kind in (("existed", "closed"), ("admitted", "error")): + if flag in msg and (kind != expected_kind or type(msg[flag]) is not bool): + raise WorkerError(f"invalid worker {flag} flag") if kind == "done": MultiplexedWorkerClient._validate_generation_terminal(msg) return kind @@ -685,9 +690,10 @@ def _dispatch(self, msg, kind): msg["error"], code=msg.get("code"), context_overflow=msg.get("context_overflow"), + admitted=msg.get("admitted"), ) if kind == "error" - else WorkerStats.from_message(msg) if state.op == "generate" else None + else WorkerStats.from_message(msg) if state.op == "generate" else msg ) self._complete(state, result) else: @@ -765,6 +771,7 @@ async def _op(self, op, session_id, ack): result = await asyncio.shield(state.completion) if isinstance(result, WorkerError): raise result + return result finally: self._abandon(state) @@ -776,9 +783,10 @@ async def reset_session(self, session_id: str) -> None: """Wait for the named session's replacement acknowledgement.""" await self._op("reset", session_id, "reset") - async def close_session(self, session_id: str) -> None: - """Wait for the named session's logical close acknowledgement.""" - await self._op("close", session_id, "closed") + async def close_session(self, session_id: str) -> Optional[bool]: + """Close idempotently; return whether it existed, or None for older workers.""" + result = await self._op("close", session_id, "closed") + return result.get("existed") async def _cleanup(self): self._reader.cancel() diff --git a/examples/llm_server/python/openai_transcript.py b/examples/llm_server/python/openai_transcript.py index da2c3fd0a17..679d2b3f716 100644 --- a/examples/llm_server/python/openai_transcript.py +++ b/examples/llm_server/python/openai_transcript.py @@ -131,6 +131,18 @@ def _assistant_fingerprint(content, tool_calls) -> str: else: continue norm.append([name, _normalize_tool_args(args)]) + if isinstance(content, list): + content = [ + {key: value for key, value in part.items() if key != "cache_control"} + for part in content + ] + if ( + len(content) == 1 + and set(content[0]) == {"type", "text"} + and content[0]["type"] == "text" + and isinstance(content[0]["text"], str) + ): + content = content[0]["text"] blob = json.dumps([content or "", norm], sort_keys=True, ensure_ascii=False) return hashlib.sha1(blob.encode("utf-8")).hexdigest() @@ -140,6 +152,32 @@ def _reasoning_fingerprint(reasoning_content: Optional[str]) -> Optional[bytes]: return None return hashlib.sha256(reasoning_content.encode("utf-8")).digest() + @classmethod + def _history_fingerprints(cls, messages: list[ChatMessage], stored: dict): + digest = hashlib.sha256() + prefixes = [digest.hexdigest()] + turn = 0 + for m in messages: + reasoning = cls._reasoning_fingerprint(m.reasoning_content) + if m.role == "assistant": + if m.reasoning_content is None and turn in stored: + reasoning = stored[turn]["reasoning_fp"] + turn += 1 + entry = json.dumps( + [ + m.role, + m.name, + m.tool_call_id, + cls._assistant_fingerprint(m.content, m.tool_calls), + reasoning.hex() if reasoning is not None else None, + ], + ensure_ascii=False, + ).encode() + digest.update(len(entry).to_bytes(8, "big")) + digest.update(entry) + prefixes.append(digest.hexdigest()) + return prefixes + def _normalize_scaffold(self, text_chunk: str, preamble: str) -> Optional[str]: """Force the scaffold region (between the last assistant header in `text_chunk` and its end) to equal `preamble`, so the worker re-tokenizes @@ -266,6 +304,7 @@ def build_prompt_input( rendered_prompt: str, tools, template_kwargs, + history_messages: Optional[list[ChatMessage]] = None, ) -> PromptInput: """Return a PromptInput: token-ID segments when this session has faithful stored ids for matching prior assistant turns, else the plain rendered @@ -273,12 +312,16 @@ def build_prompt_input( records and only spliced when its content/tool calls and any supplied reasoning string match what we returned, and we kept faithful ids for it. Omitted or null reasoning permits reuse; a string edit invalidates the tail. + ``history_messages`` preserves source content before model normalization, + such as image URLs replaced with placeholders for rendering. Falls back to text on a sentinel collision or a render that dropped/duplicated a sentinel.""" stored = self._turns.get(session_id or "") if not stored: return PromptInput(text=rendered_prompt) + history_messages = messages if history_messages is None else history_messages # Missing records render as text without shifting later turn indices. + prefixes = self._history_fingerprints(history_messages, stored) positions = [i for i, m in enumerate(messages) if m.role == "assistant"] splice: dict[int, dict] = {} # message index -> {"ids", "preamble"} for k, pos in enumerate(positions): @@ -286,10 +329,14 @@ def build_prompt_input( if record is None: continue m = messages[pos] - if self._assistant_fingerprint(m.content, m.tool_calls) != record["fp"] or ( - m.reasoning_content is not None - and self._reasoning_fingerprint(m.reasoning_content) - != record["reasoning_fp"] + if ( + record.get("history_fp") != prefixes[pos] + or self._assistant_fingerprint(m.content, m.tool_calls) != record["fp"] + or ( + m.reasoning_content is not None + and self._reasoning_fingerprint(m.reasoning_content) + != record["reasoning_fp"] + ) ): # Discard the stale tail without shifting subsequent turn indices. self._turns[session_id or ""] = { @@ -353,6 +400,7 @@ def record_assistant_turn( prior_turns: int, preamble: str = "", reasoning_content: Optional[str] = None, + request_messages: list[ChatMessage], ) -> None: """Record this turn's {fingerprint, generated ids, generation preamble} at `prior_turns` (the assistant-turn count of the request it answers). @@ -370,6 +418,7 @@ def record_assistant_turn( for index in [index for index in turns if index >= prior_turns]: del turns[index] turns[prior_turns] = { + "history_fp": self._history_fingerprints(request_messages, turns)[-1], "fp": self._assistant_fingerprint(content, tool_calls), "reasoning_fp": self._reasoning_fingerprint(reasoning_content), "ids": ( diff --git a/examples/llm_server/python/serving_chat.py b/examples/llm_server/python/serving_chat.py index 5c77fd1941a..017ab7e10c1 100644 --- a/examples/llm_server/python/serving_chat.py +++ b/examples/llm_server/python/serving_chat.py @@ -13,7 +13,8 @@ import json import logging import math -from contextlib import aclosing +from contextlib import aclosing, asynccontextmanager +from dataclasses import dataclass from typing import AsyncIterator, Callable, Iterable, Optional, Protocol from .chat_template import ChatTemplate @@ -130,6 +131,74 @@ def feed(self, text: str) -> Iterable[DeltaMessage]: ... def finish(self) -> Iterable[DeltaMessage]: ... +def _session_capacity_exhausted(error): + return ( + isinstance(error, WorkerError) + and error.code == "session_capacity_exhausted" + or isinstance(error, SessionCapacity) + and error.session_slots_exhausted + ) + + +@dataclass +class _AdmissionState: + pending: bool = False + admitted: bool = False + retried: bool = False + + +class _AdmissionRetryStream: + """Retry one session-slot rejection before any raw model output.""" + + def __init__(self, serving, session_id, generation, factory, admission): + self._serving = serving + self._session_id = session_id + self._generation = generation + self._factory = factory + self._admission = admission + self._started = False + self._closed = False + self._cancel_requested = False + + def __aiter__(self): + return self + + async def __anext__(self): + while not self._closed: + try: + token = await anext(self._generation) + self._started = True + if self._session_id is not None: + self._admission.admitted = True + return token + except StopAsyncIteration: + if self._session_id is not None: + self._admission.admitted = True + raise + except (WorkerError, APIError) as error: + if not await self._serving._retry_admission( + error, + self._admission, + generation=self._generation, + started=self._started, + ): + raise + if self._closed or self._cancel_requested: + raise StopAsyncIteration + if self._session_id is not None: + self._admission.pending = True + self._generation = self._factory() + raise StopAsyncIteration + + def cancel(self): + self._cancel_requested = True + return self._generation.cancel() + + async def aclose(self): + self._closed = True + await self._generation.aclose() + + class ServingChat: def __init__( self, @@ -147,6 +216,7 @@ def __init__( streaming_parser_factory: Optional[ Callable[[], StreamingResponseParser] ] = None, + evict_idle_sessions: bool = False, streaming_preflight_timeout: Optional[float] = None, api_dialect: str = "openai", ): @@ -158,6 +228,10 @@ def __init__( self._content_filter = content_filter self._reasoning_extractor = reasoning_extractor self._streaming_parser_factory = streaming_parser_factory + self._evict_idle_sessions = evict_idle_sessions + self._idle_sessions: dict[str, None] = {} + self._cleanup_sessions: dict[str, None] = {} + self._eviction_lock = asyncio.Lock() self._streaming_preflight_timeout = streaming_preflight_timeout if api_dialect not in ("openai", "openrouter"): raise ValueError("api_dialect must be openai or openrouter") @@ -467,7 +541,11 @@ def _generation_error(error: Exception) -> APIError: counts["context_length"], counts["completion_tokens"], ) - if error.code in ("capacity_exhausted", "unsupported_session"): + if error.code in ( + "capacity_exhausted", + "session_capacity_exhausted", + "unsupported_session", + ): return SessionCapacity(error.code) if error.code is not None: status = { @@ -486,28 +564,40 @@ def _generation_error(error: Exception) -> APIError: ) return GenerationError(str(error)) - async def _preflight_session(self, session_id: str) -> None: + async def _preflight_session(self, session_id: str, admission) -> None: """Reserve the session before any response bytes are emitted so a capacity refusal becomes an HTTP status, not an SSE error event.""" try: await self._runtime.open(session_id) except WorkerError as e: + if not self._multiplexed and e.code == "capacity_exhausted": + raise SessionCapacity("session_capacity_exhausted") from e raise self._generation_error(e) + if self._evict_idle_sessions: + admission.admitted = True async def close_session(self, session_id: str) -> None: self._validate_session_id(session_id) try: async with self._transactions.hold(session_id): - try: - await self._runtime.close(session_id) - except BaseException: - if self._multiplexed: - self._transcript.close(session_id) - raise - self._transcript.close(session_id) + await self._close_session(session_id) except WorkerError as e: raise self._generation_error(e) + async def _close_session(self, session_id): + try: + existed = await self._runtime.close(session_id) + except WorkerError as error: + if error.code == "session_not_found": + self._transcript.close(session_id) + self._idle_sessions.pop(session_id, None) + self._cleanup_sessions.pop(session_id, None) + raise + self._transcript.close(session_id) + self._idle_sessions.pop(session_id, None) + self._cleanup_sessions.pop(session_id, None) + return existed + async def reset_session(self, session_id: str) -> None: self._validate_session_id(session_id) try: @@ -677,15 +767,74 @@ def _count_prompt_tokens(self, prompt: PromptInput) -> Optional[int]: total += c return total + @asynccontextmanager + async def _transaction(self, session_id): + admission = _AdmissionState() + async with self._transactions.hold(session_id): + known = session_id in self._idle_sessions + self._idle_sessions.pop(session_id, None) + try: + yield admission + finally: + if self._evict_idle_sessions and session_id is not None: + if known or admission.admitted: + self._cleanup_sessions.pop(session_id, None) + self._idle_sessions[session_id] = None + elif admission.pending: + self._cleanup_sessions[session_id] = None + if not self._runtime.session_in_use(session_id): + cleanup = asyncio.create_task( + self._close_session(session_id) + ) + try: + await SessionRuntime._finish_cleanup(cleanup) + except WorkerError as error: + if error.code != "session_not_found": + logger.warning( + "Could not close unconfirmed session %s", + session_id, + ) + + async def _evict_idle_session(self) -> bool: + if not self._evict_idle_sessions: + return False + async with self._eviction_lock: + for session_id in [*self._cleanup_sessions, *self._idle_sessions]: + if self._transactions.in_use( + session_id + ) or self._runtime.session_in_use(session_id): + continue # Includes queued callers, not just the owner. + # The admitting chat already owns a request slot. + async with self._transactions.hold(session_id, count_request=False): + try: + existed = await self._close_session(session_id) + if existed is False: + continue + except WorkerError as error: + if error.code == "session_not_found": + continue + if error.admitted is False: + return False # The close never reached the worker. + candidates = ( + self._cleanup_sessions + if session_id in self._cleanup_sessions + else self._idle_sessions + ) + candidates.pop(session_id, None) + candidates[session_id] = None + return False + return True + return False + async def create(self, req: ChatCompletionRequest): if req.session_id is not None: self._validate_session_id(req.session_id) - lease = self._transactions.hold(req.session_id) + lease = self._transaction(req.session_id) entered = False try: - await lease.__aenter__() + admission = await lease.__aenter__() entered = True - result = await self._create(req, lease) + result = await self._create(req, lease, admission) if req.stream: entered = False # The returned iterator owns the transaction. return result @@ -695,7 +844,7 @@ async def create(self, req: ChatCompletionRequest): if entered: await lease.__aexit__(None, None, None) - async def _create(self, req: ChatCompletionRequest, lease): + async def _create(self, req: ChatCompletionRequest, lease, admission): if req.model is not None and req.model != self._model_id: raise ModelNotFound(req.model, self._model_id) self._reject_invalid_values(req) @@ -750,50 +899,106 @@ async def _create(self, req: ChatCompletionRequest, lease): template_kwargs, tools=template_tools ) return await self._create_response( - req, prompt_input, options, preamble, gen_stops, lease + req, prompt_input, options, preamble, gen_stops, lease, admission ) + async def _retry_admission( + self, error, admission, *, generation=None, started=False + ): + slots_exhausted = _session_capacity_exhausted(error) + if not started and ( + slots_exhausted + or isinstance(error, WorkerError) + and error.admitted is False + ): + admission.pending = False + if asyncio.current_task().cancelling(): + raise asyncio.CancelledError + if started or admission.retried or not slots_exhausted: + return False + admission.retried = True + if generation is not None: + await generation.aclose() + if asyncio.current_task().cancelling(): + raise asyncio.CancelledError + evicted = await self._evict_idle_session() + if asyncio.current_task().cancelling(): + raise asyncio.CancelledError + return evicted + async def _create_response( - self, req, prompt_input, options, preamble, gen_stops, lease + self, req, prompt_input, options, preamble, gen_stops, lease, admission + ): + while True: + try: + return await self._start_response( + req, prompt_input, options, preamble, gen_stops, lease, admission + ) + except (WorkerError, APIError) as error: + if not await self._retry_admission(error, admission): + raise + + async def _start_response( + self, + req, + prompt_input, + options, + preamble, + gen_stops, + lease, + admission, ): # Legacy workers need explicit admission. Multiplexed generation opens # implicitly from its full prompt, preserving new-session prefix reuse. if req.session_id is not None and not self._multiplexed: - await self._preflight_session(req.session_id) - if req.stream: - stats = GenStats() - generation = self._runtime.generate_stream( - req.session_id, prompt_input, options, stats + await self._preflight_session(req.session_id, admission) + if ( + self._evict_idle_sessions + and self._multiplexed + and req.session_id is not None + ): + admission.pending = True + stats = GenStats() + generation = self._runtime.generate_stream( + req.session_id, prompt_input, options, stats + ) + if req.stream and self._multiplexed: + await generation.wait_ready(timeout=self._streaming_preflight_timeout) + if self._multiplexed and self._evict_idle_sessions: + generation = _AdmissionRetryStream( + self, + req.session_id, + generation, + lambda: self._runtime.generate_stream( + req.session_id, prompt_input, options, stats + ), + admission, ) - if self._multiplexed: - await generation.wait_ready(timeout=self._streaming_preflight_timeout) + if req.stream: return _ChatStream( self._stream(req, preamble, gen_stops, generation, stats), lease, generation, ) - return await self._complete(req, prompt_input, options, preamble, gen_stops) + return await self._complete(req, preamble, gen_stops, generation, stats) async def _complete( self, req: ChatCompletionRequest, - prompt: PromptInput, - options: GenerationOptions, - preamble: str = "", - gen_stops: Optional[list[str]] = None, + preamble: str, + gen_stops: list[str], + generation, + stats: GenStats, ) -> ChatCompletionResponse: - # Same stop set the worker was given (per-path: narrow for tools, broad - # content specials added for plain chat); falls back to narrow if a caller - # didn't supply it. - stops = gen_stops if gen_stops is not None else self._generation_stops(req) - stats = GenStats() + # Same stop set the worker was given (narrow for tools, broad for chat). + stops = gen_stops try: # Collect raw text (markers intact for tool parsing), halting early # at a stop boundary (special token or request stop). - async with aclosing( - self._runtime.generate_stream(req.session_id, prompt, options, stats) - ) as generation: + async with aclosing(generation): text, stopped = await self._collect_until_stop(generation, stops) + except (WorkerError, APIError): + raise except Exception as e: # noqa: BLE001 - surface as a structured API error raise self._generation_error(e) # Bound the raw output at the first stop/special token BEFORE tool @@ -804,6 +1009,7 @@ async def _complete( # Compare future echoes against the finalized client-visible response. self._transcript.record_assistant_turn( session_id=req.session_id, + request_messages=req.messages, content=content, tool_calls=tool_calls, reasoning_content=reasoning, @@ -1061,6 +1267,7 @@ def chunk(delta: DeltaMessage, finish=None) -> str: return self._transcript.record_assistant_turn( session_id=req.session_id, + request_messages=req.messages, content=content, tool_calls=tool_calls, reasoning_content=reasoning or None, diff --git a/examples/llm_server/python/session_runtime.py b/examples/llm_server/python/session_runtime.py index 3b4a2e98886..e6fd747146d 100644 --- a/examples/llm_server/python/session_runtime.py +++ b/examples/llm_server/python/session_runtime.py @@ -280,11 +280,14 @@ def __init__(self, limit=None): self._users = 0 self._limit = limit + def in_use(self, session_id): + return session_id in self._entries + @asynccontextmanager - async def hold(self, session_id): - if self._limit is not None and self._users >= self._limit: + async def hold(self, session_id, *, count_request=True): + if count_request and self._limit is not None and self._users >= self._limit: raise WorkerError("request capacity exhausted", code="capacity_exhausted") - self._users += 1 + self._users += int(count_request) entry = None if session_id is not None: entry = self._entries.setdefault(session_id, _SessionLock()) @@ -296,7 +299,7 @@ async def hold(self, session_id): async with entry.lock: yield finally: - self._users -= 1 + self._users -= int(count_request) if entry is not None: entry.users -= 1 if not entry.users: @@ -500,6 +503,9 @@ def __init__( self._abort_timeout_seconds = abort_timeout_seconds self._failure: Optional[WorkerError] = None + def session_in_use(self, session_id: str) -> bool: + return self._session_locks.in_use(session_id) + @property def healthy(self) -> bool: if self._failure is not None: @@ -529,9 +535,9 @@ async def reset(self, session_id: str) -> None: """Replace a named session's context under the same public ID.""" await self._session_op("reset_session", session_id) - async def close(self, session_id: str) -> None: + async def close(self, session_id: str) -> Optional[bool]: """Destroy a named session and free its state and capacity slot.""" - await self._session_op("close_session", session_id) + return await self._session_op("close_session", session_id) def _own_task(self, task): self._native_tasks.add(task) @@ -569,7 +575,9 @@ async def _operation(self, session_id): return if self._admitted >= self.max_concurrent_requests: raise WorkerError( - "worker request capacity exhausted", code="capacity_exhausted" + "worker request capacity exhausted", + code="capacity_exhausted", + admitted=False, ) self._admitted += 1 if self._native: @@ -597,7 +605,7 @@ async def _operation(self, session_id): await lease.__aexit__(None, None, None) self._release_admission(operation) - async def _session_op(self, method: str, session_id: str) -> None: + async def _session_op(self, method: str, session_id: str): op = getattr(self._worker, method, None) if op is None: return @@ -609,7 +617,7 @@ async def _session_op(self, method: str, session_id: str) -> None: if self._native else loop.run_in_executor(self._executor, op, session_id) ) - await asyncio.shield(operation.future) + return await asyncio.shield(operation.future) def stop(self) -> bool: """Legacy stop helper; multiplexed callers must cancel their Generation.""" diff --git a/examples/llm_server/python/tests/conftest.py b/examples/llm_server/python/tests/conftest.py index 045e3cf723d..26e98fa1af4 100644 --- a/examples/llm_server/python/tests/conftest.py +++ b/examples/llm_server/python/tests/conftest.py @@ -214,6 +214,7 @@ def _make( content_filter=None, special_tokens=None, api_dialect="openai", + evict_idle_sessions=False, ): fake = FakeRunner( tokens, @@ -245,6 +246,7 @@ def _make( streaming_parser_factory=streaming_parser_factory, content_filter=content_filter, api_dialect=api_dialect, + evict_idle_sessions=evict_idle_sessions, ) return TestClient(build_app(serving, "test-model")), fake diff --git a/examples/llm_server/python/tests/test_concurrent_serving.py b/examples/llm_server/python/tests/test_concurrent_serving.py index 0f224b1ea29..e05d9beb290 100644 --- a/examples/llm_server/python/tests/test_concurrent_serving.py +++ b/examples/llm_server/python/tests/test_concurrent_serving.py @@ -828,6 +828,7 @@ async def scenario(): runtime = SessionRuntime(worker) serving = _chat(runtime) serving._transcript.record_assistant_turn( + request_messages=[], session_id="s", content="reply", tool_calls=None, @@ -872,6 +873,7 @@ async def scenario(): runtime = SessionRuntime(worker) serving = _chat(runtime) serving._transcript.record_assistant_turn( + request_messages=[], session_id="s", content="reply", tool_calls=None, diff --git a/examples/llm_server/python/tests/test_multiplexed_worker_client.py b/examples/llm_server/python/tests/test_multiplexed_worker_client.py index 9e2088b05fa..5533061f4e9 100644 --- a/examples/llm_server/python/tests/test_multiplexed_worker_client.py +++ b/examples/llm_server/python/tests/test_multiplexed_worker_client.py @@ -323,6 +323,7 @@ async def scenario(): with pytest.raises(WorkerError) as full: client.reserve_request() assert full.value.code == "capacity_exhausted" + assert full.value.admitted is False assert slow.request_id in client._requests proc.send(cancel["request_id"], cancelled=True) proc.send(slow.request_id, done=True, cancelled=True, finish_reason="stop") @@ -342,16 +343,20 @@ async def scenario(): await proc.stdin.frames.get() assert stream.cancel() cancel = await proc.stdin.frames.get() - proc.send(cancel["request_id"], error="cannot cancel", code="busy") + proc.send( + cancel["request_id"], error="cannot cancel", code="busy", admitted=False + ) with pytest.raises(WorkerError, match="cannot cancel") as error: await anext(stream) assert error.value.code == "busy" + assert error.value.admitted is None waiter = asyncio.create_task(stream.wait()) await asyncio.sleep(0) assert not waiter.done() with pytest.raises(WorkerError) as full: client.reserve_request() assert full.value.code == "capacity_exhausted" + assert full.value.admitted is False proc.send( stream.request_id, done=True, cancelled=True, finish_reason="stop" ) @@ -377,6 +382,7 @@ async def scenario(): with pytest.raises(WorkerError) as full: client.reserve_request() assert full.value.code == "capacity_exhausted" + assert full.value.admitted is False assert await anext(stream) == "first" assert await anext(stream) == "second" if terminal_error: @@ -542,6 +548,10 @@ async def scenario(): {"request_id": 1, "done": True, "cancelled": True, "finish_reason": "length"}, {"request_id": 1, "error": 4}, {"request_id": 1, "error": "bad", "code": False}, + {"request_id": 1, "error": "bad", "admitted": 0}, + {"request_id": 1, "done": True, "admitted": False}, + {"request_id": 1, "closed": True, "existed": 1}, + {"request_id": 1, "opened": True, "existed": False}, {"request_id": 1, "opened": True}, {"request_id": 1, "cancelled": True}, {"request_id": True, "done": True}, @@ -757,6 +767,7 @@ async def scenario(): with pytest.raises(WorkerError) as full: client.reserve_request() assert full.value.code == "capacity_exhausted" + assert full.value.admitted is False assert not client.release_request(999) assert client.release_request(b_id) client._next_request_id = _UINT64_MAX @@ -1701,3 +1712,25 @@ async def scenario(): ) _run(scenario()) + + +@pytest.mark.parametrize("admitted", [None, False, True]) +def test_wire_admission_error_preserves_known_admission(admitted): + async def scenario(): + proc = _AsyncProc() + client = MultiplexedWorkerClient(proc) + try: + stream = client.generate("hi", SimpleNamespace(max_new_tokens=1)) + await proc.stdin.frames.get() + fields = {} if admitted is None else {"admitted": admitted} + proc.send( + stream.request_id, error="full", code="capacity_exhausted", **fields + ) + with pytest.raises(WorkerError) as error: + await anext(stream) + assert error.value.code == "capacity_exhausted" + assert error.value.admitted is admitted + finally: + await client.close() + + _run(scenario()) diff --git a/examples/llm_server/python/tests/test_multiplexed_worker_client_native.py b/examples/llm_server/python/tests/test_multiplexed_worker_client_native.py index 32c17d005a0..7195a87f2d6 100644 --- a/examples/llm_server/python/tests/test_multiplexed_worker_client_native.py +++ b/examples/llm_server/python/tests/test_multiplexed_worker_client_native.py @@ -153,3 +153,14 @@ async def bounded(): await asyncio.wait_for(scenario(), timeout=15) asyncio.run(bounded()) + + +def test_idempotent_native_close_reports_whether_it_freed_a_slot(active_peer): + async def scenario(): + async with active_peer() as (client, peer, gate): + await _finish_peer_and_reuse(client, peer, gate) + assert await client.close_session("missing") is False + assert await client.close_session("session-\U0001f600") is True + assert await client.close_session("session-\U0001f600") is False + + asyncio.run(asyncio.wait_for(scenario(), 15)) diff --git a/examples/llm_server/python/tests/test_native_session_eviction.py b/examples/llm_server/python/tests/test_native_session_eviction.py new file mode 100644 index 00000000000..8bee561c90b --- /dev/null +++ b/examples/llm_server/python/tests/test_native_session_eviction.py @@ -0,0 +1,127 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Session-slot recovery through the real C++ worker and JSONL client.""" + +import asyncio +import json +from unittest.mock import patch + +import pytest +from executorch.examples.llm_server.python.chat_template import ChatTemplate +from executorch.examples.llm_server.python.protocol import ChatCompletionRequest +from executorch.examples.llm_server.python.serving_chat import ServingChat +from executorch.examples.llm_server.python.session_runtime import SessionRuntime + + +def request(sid=None, stream=False): + return ChatCompletionRequest( + messages=[{"role": "user", "content": "hi"}], + session_id=sid, + max_tokens=1, + stream=stream, + ) + + +@pytest.mark.parametrize("stream,timeout", [(False, None), (True, None), (True, 0)]) +@pytest.mark.parametrize("stale", [False, True]) +@pytest.mark.parametrize("sid", [None, "new"]) +def test_native_slot_refusal_evicts_and_retries( + active_peer, stream, timeout, stale, sid +): + async def scenario(): + async with active_peer() as (worker, peer, gate): + await asyncio.get_running_loop().sock_sendall(gate, b"R") + async for _ in peer: + pass + await peer.wait() + runtime = SessionRuntime(worker) + serving = ServingChat( + runtime, + ChatTemplate(allow_fallback=True), + "test", + evict_idle_sessions=True, + streaming_preflight_timeout=timeout, + ) + assert runtime._native + for existing in ("a", "b", "c", "d"): + await serving.create(request(existing)) + if stale: + assert await worker.close_session("a") is True + await worker.open_session("outside") + with patch.object( + worker._proc.stdin, "write", wraps=worker._proc.stdin.write + ) as write: + result = await serving.create(request(sid, stream)) + if stream: + output = "".join([chunk async for chunk in result]) + assert '"error"' not in output + chunks = [ + json.loads(line[6:]) + for line in output.splitlines() + if line.startswith("data: {") + ] + assert chunks[-1]["choices"][0]["finish_reason"] == "length" + else: + assert result.choices[0].message.content == 'x\n"' + assert result.choices[0].finish_reason == "length" + frames = [json.loads(call.args[0]) for call in write.call_args_list] + assert [frame["op"] for frame in frames] == ( + ["generate", "close", "close", "generate"] + if stale + else ["generate", "close", "generate"] + ) + assert [ + frame["session_id"] for frame in frames if frame["op"] == "close" + ] == (["a", "b"] if stale else ["a"]) + expected = (["c", "d"] if stale else ["b", "c", "d"]) + ( + [sid] if sid else [] + ) + assert list(serving._idle_sessions) == expected + assert not serving._cleanup_sessions + assert not runtime._admitted + assert await worker.close_session("a") is False + if stale: + assert await worker.close_session("b") is False + if sid is None: + # Anonymous generation released its temporary slot. Filling it + # directly succeeds without evicting another named session. + await worker.open_session("temporary-slot-was-released") + assert list(serving._idle_sessions) == expected + assert worker.healthy + + asyncio.run(asyncio.wait_for(scenario(), 15)) + + +def test_local_close_overload_preserves_native_lru(active_peer): + async def scenario(): + async with active_peer() as (worker, peer, gate): + await asyncio.get_running_loop().sock_sendall(gate, b"R") + async for _ in peer: + pass + await peer.wait() + runtime = SessionRuntime(worker, max_concurrent_requests=1) + serving = ServingChat( + runtime, + ChatTemplate(allow_fallback=True), + "test", + evict_idle_sessions=True, + ) + for sid in ("a", "b"): + await serving.create(request(sid)) + with patch.object( + worker._proc.stdin, "write", wraps=worker._proc.stdin.write + ) as write: + # Hold a real runtime admission lease; the close fails locally. + async with runtime._operation("busy"): + assert await serving._evict_idle_session() is False + write.assert_not_called() + assert list(serving._idle_sessions) == ["a", "b"] + assert await serving._evict_idle_session() is True + assert list(serving._idle_sessions) == ["b"] + assert await worker.close_session("a") is False + assert await worker.close_session("b") is True + + asyncio.run(asyncio.wait_for(scenario(), 15)) diff --git a/examples/llm_server/python/tests/test_openrouter_recovery.py b/examples/llm_server/python/tests/test_openrouter_recovery.py index b344ab0fdbb..3118bcb12b9 100644 --- a/examples/llm_server/python/tests/test_openrouter_recovery.py +++ b/examples/llm_server/python/tests/test_openrouter_recovery.py @@ -3,11 +3,19 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. +import asyncio import json import re +import threading +from contextlib import AsyncExitStack import pytest -from executorch.examples.llm_server.python.worker_client import WorkerError +from executorch.examples.llm_server.python.chat_template import ChatTemplate +from executorch.examples.llm_server.python.protocol import ChatCompletionRequest +from executorch.examples.llm_server.python.serving_chat import ServingChat +from executorch.examples.llm_server.python.session_runtime import SessionRuntime +from executorch.examples.llm_server.python.tests.test_concurrent_serving import _Worker +from executorch.examples.llm_server.python.worker_client import WorkerError, WorkerStats def muse_overflow_counts(body): @@ -108,3 +116,131 @@ def fail(*args, **kwargs): assert len(errors) == 1 assert muse_overflow_counts(errors[0]) == (99, 110) assert '"finish_reason"' not in response.text + + +class CapacityWorker(_Worker): + def __init__(self, late, after_output=False): + super().__init__() + self.sessions = set() + self.closed_sessions = [] + self.attempts = [] + self.admission = threading.Event() + self.after_output = after_output + if not late: + self.admission.set() + + def generate(self, prompt, config, token_callback, stats_callback, request_id): + try: + sid = config.session_id + self.attempts.append(sid) + if sid not in self.sessions and len(self.sessions) >= 2: + assert self.admission.wait(5) + if self.after_output: + token_callback("partial " * 8) + raise WorkerError( + "all sessions full", code="session_capacity_exhausted" + ) + self.sessions.add(sid) + token_callback("ok") + stats_callback( + WorkerStats( + num_prompt_tokens=3, num_generated_tokens=1, finish_reason="stop" + ) + ) + finally: + self.release_request(request_id) + + def close_session(self, sid): + existed = sid in self.sessions + self.sessions.discard(sid) + self.closed_sessions.append(sid) + return existed + + +@pytest.mark.parametrize("late", [False, True]) +@pytest.mark.parametrize("limit", [1, 4]) +def test_stream_admission_evicts_idle_sessions_before_or_after_preflight(late, limit): + async def scenario(): + worker = CapacityWorker(late) + runtime = SessionRuntime(worker, max_concurrent_requests=limit) + serving = ServingChat( + runtime, + ChatTemplate(allow_fallback=True), + "test", + evict_idle_sessions=True, + streaming_preflight_timeout=0.01, + ) + + def request(sid, stream=False): + return ChatCompletionRequest( + messages=[{"role": "user", "content": "hi"}], + session_id=sid, + stream=stream, + ) + + try: + for sid in ["a", "b"]: + await serving.create(request(sid)) + stream = await serving.create(request("c", True)) + worker.admission.set() + output = "".join([chunk async for chunk in stream]) + assert '"content":"ok"' in output + assert '"error"' not in output + assert worker.closed_sessions == ["a"] + assert worker.sessions == set(serving._idle_sessions) == {"b", "c"} + assert not serving._cleanup_sessions + finally: + worker.admission.set() + await runtime.aclose_worker() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("after_output", [False, True]) +def test_late_error_never_evicts_active_sessions_or_replays_output(after_output): + async def scenario(): + worker = CapacityWorker(True, after_output) + runtime = SessionRuntime(worker) + serving = ServingChat( + runtime, + ChatTemplate(allow_fallback=True), + "test", + evict_idle_sessions=True, + streaming_preflight_timeout=0.01, + ) + + def request(sid, stream=False): + return ChatCompletionRequest( + messages=[{"role": "user", "content": "hi"}], + session_id=sid, + stream=stream, + ) + + try: + for sid in ["a", "b"]: + await serving.create(request(sid)) + async with AsyncExitStack() as stack: + if not after_output: + await stack.enter_async_context(serving._transactions.hold("a")) + await stack.enter_async_context(serving._transactions.hold("b")) + stream = await serving.create(request("c", True)) + worker.admission.set() + output = "".join([chunk async for chunk in stream]) + errors = [ + json.loads(line[6:])["error"] + for line in output.splitlines() + if line.startswith('data: {"error"') + ] + assert len(errors) == 1 + assert errors[0]["code"] == "capacity_exhausted" + assert not worker.closed_sessions + assert worker.attempts.count("c") == 1 + if not after_output: + assert set(serving._idle_sessions) == {"a", "b"} + else: + assert '"content":"partial ' in output + finally: + worker.admission.set() + await runtime.aclose_worker() + + asyncio.run(scenario()) diff --git a/examples/llm_server/python/tests/test_session_eviction.py b/examples/llm_server/python/tests/test_session_eviction.py new file mode 100644 index 00000000000..08587adba2c --- /dev/null +++ b/examples/llm_server/python/tests/test_session_eviction.py @@ -0,0 +1,694 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import asyncio +from contextlib import aclosing + +import pytest +from executorch.examples.llm_server.python.chat_template import ChatTemplate +from executorch.examples.llm_server.python.openai_transcript import ( + OpenAITranscriptState, +) +from executorch.examples.llm_server.python.protocol import ChatMessage +from executorch.examples.llm_server.python.worker_client import WorkerError + + +def test_request_overload_never_evicts_sessions(make_client): + client, worker = make_client(max_named_sessions=3, evict_idle_sessions=True) + body = {"messages": [{"role": "user", "content": "hi"}]} + for sid in ("a", "b", "c"): + assert ( + client.post( + "/v1/chat/completions", json={**body, "session_id": sid} + ).status_code + == 200 + ) + attempts = [] + + def overloaded(*args, **kwargs): + attempts.append(True) + raise WorkerError("request limit reached", code="capacity_exhausted") + + worker.generate = overloaded + assert client.post("/v1/chat/completions", json=body).status_code == 429 + assert len(attempts) == 1 + assert worker.open_named == {"a", "b", "c"} + + +def test_close_failure_preserves_session_and_rotates_victim(make_client): + client, worker = make_client( + max_named_sessions=2, evict_idle_sessions=True, gen_ids=[1] + ) + body = {"messages": [{"role": "user", "content": "hi"}]} + for sid in ("a", "b"): + assert ( + client.post( + "/v1/chat/completions", json={**body, "session_id": sid} + ).status_code + == 200 + ) + close = worker.close_session + + def fail_a(sid): + if sid == "a": + raise WorkerError("close overloaded", code="capacity_exhausted") + return close(sid) + + worker.close_session = fail_a + serving = client.app.state.serving + assert ( + client.post( + "/v1/chat/completions", json={**body, "session_id": "c"} + ).status_code + == 429 + ) + assert "a" in serving._transcript._turns + assert list(serving._idle_sessions) == ["b", "a"] + assert ( + client.post( + "/v1/chat/completions", json={**body, "session_id": "c"} + ).status_code + == 200 + ) + assert worker.open_named == {"a", "c"} + + +@pytest.mark.parametrize("opened", [False, True]) +@pytest.mark.parametrize("code", ["invalid_argument", "capacity_exhausted"]) +def test_unconfirmed_native_admission_cleanup_is_retried(opened, code): + from executorch.examples.llm_server.python.errors import APIError + from executorch.examples.llm_server.python.protocol import ChatCompletionRequest + from executorch.examples.llm_server.python.serving_chat import ServingChat + from executorch.examples.llm_server.python.session_runtime import SessionRuntime + from executorch.examples.llm_server.python.tests.test_openrouter_recovery import ( + CapacityWorker, + ) + + class Worker(CapacityWorker): + def __init__(self): + super().__init__(False) + self.fail_close = opened + + def generate(self, prompt, config, token_callback, stats_callback, request_id): + if config.session_id == "bad": + if opened: + self.sessions.add("bad") + self.release_request(request_id) + raise WorkerError("generation failed before output", code=code) + return super().generate( + prompt, config, token_callback, stats_callback, request_id + ) + + def close_session(self, sid): + if self.fail_close: + self.fail_close = False + raise WorkerError("close queue full", code="capacity_exhausted") + return super().close_session(sid) + + async def scenario(): + worker = Worker() + runtime = SessionRuntime(worker) + serving = ServingChat( + runtime, ChatTemplate(allow_fallback=True), "test", evict_idle_sessions=True + ) + + def request(sid): + return ChatCompletionRequest( + messages=[ChatMessage(role="user", content="hi")], session_id=sid + ) + + try: + with pytest.raises(APIError): + await serving.create(request("bad")) + assert not serving._idle_sessions + assert list(serving._cleanup_sessions) == (["bad"] if opened else []) + await serving.create(request("b")) + await serving.create(request("c")) + assert worker.sessions == {"b", "c"} + assert not serving._cleanup_sessions + assert worker.attempts.count("c") == (2 if opened else 1) + finally: + await runtime.aclose_worker() + + asyncio.run(scenario()) + + +def test_stale_idle_candidate_does_not_consume_the_retry(): + from executorch.examples.llm_server.python.protocol import ChatCompletionRequest + from executorch.examples.llm_server.python.serving_chat import ServingChat + from executorch.examples.llm_server.python.session_runtime import SessionRuntime + from executorch.examples.llm_server.python.tests.test_openrouter_recovery import ( + CapacityWorker, + ) + + async def scenario(): + worker = CapacityWorker(False) + runtime = SessionRuntime(worker) + serving = ServingChat( + runtime, ChatTemplate(allow_fallback=True), "test", evict_idle_sessions=True + ) + + def request(sid): + return ChatCompletionRequest( + messages=[ChatMessage(role="user", content="hi")], session_id=sid + ) + + try: + for sid in ("a", "b"): + await serving.create(request(sid)) + worker.sessions.remove("a") + await serving.create(request("d")) + await serving.create(request("c")) + assert worker.sessions == {"c", "d"} + assert worker.attempts.count("c") == 2 + assert not serving._cleanup_sessions + finally: + await runtime.aclose_worker() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("close", [False, True]) +def test_cancellation_during_eviction_does_not_create_replacement(close): + from executorch.examples.llm_server.python.serving_chat import ( + _AdmissionRetryStream, + _AdmissionState, + ServingChat, + ) + + async def scenario(): + entered, release = asyncio.Event(), asyncio.Event() + replacements = [] + + class Serving: + _retry_admission = ServingChat._retry_admission + + async def _evict_idle_session(self): + entered.set() + await release.wait() + return True + + class Rejected: + async def __anext__(self): + raise WorkerError("slots full", code="session_capacity_exhausted") + + async def aclose(self): + pass + + def cancel(self): + return True + + stream = _AdmissionRetryStream( + Serving(), + "s", + Rejected(), + lambda: replacements.append(True), + _AdmissionState(pending=True), + ) + read = asyncio.create_task(anext(stream)) + await asyncio.wait_for(entered.wait(), 2) + if close: + await stream.aclose() + else: + stream.cancel() + release.set() + with pytest.raises(StopAsyncIteration): + await read + assert not replacements + + asyncio.run(scenario()) + + +def test_history_prefix_hashing_serializes_each_message_once(monkeypatch): + state = OpenAITranscriptState(ChatTemplate(allow_fallback=True)) + messages = [ChatMessage(role="user", content="large image source" * 100)] + for i in range(20): + state.record_assistant_turn( + session_id="s", + request_messages=messages, + content="OK", + tool_calls=None, + generated_token_ids=[100 + i], + prior_turns=i, + ) + messages.extend( + [ + ChatMessage(role="assistant", content="OK"), + ChatMessage(role="user", content="continue"), + ] + ) + original = state._assistant_fingerprint + calls = [] + + def counted(content, tools): + calls.append(content) + return original(content, tools) + + monkeypatch.setattr( + OpenAITranscriptState, "_assistant_fingerprint", staticmethod(counted) + ) + state.build_prompt_input( + session_id="s", + messages=messages, + rendered_prompt=state._template.render(messages), + tools=None, + template_kwargs=None, + ) + assert calls.count(messages[0].content) == 1 + + +def test_idle_session_eviction_releases_capacity_and_transcript(make_client): + client, worker = make_client( + max_named_sessions=2, evict_idle_sessions=True, gen_ids=[1] + ) + + def chat(sid): + return client.post( + "/v1/chat/completions", + json={"messages": [{"role": "user", "content": "hi"}], "session_id": sid}, + ) + + assert chat("a").status_code == chat("b").status_code == 200 + assert chat("a").status_code == 200 # a is newer than b + assert chat("c").status_code == 200 + assert worker.open_named == {"a", "c"} + serving = client.app.state.serving + assert "b" not in serving._transcript._turns + for i in range(20): + assert chat(f"next-{i}").status_code == 200 + assert len(worker.open_named) == len(serving._idle_sessions) == 2 + + +def test_history_identity_prevents_same_answer_collision(): + template = ChatTemplate(allow_fallback=True) + state = OpenAITranscriptState(template) + u1 = ChatMessage(role="user", content="removed question") + u2 = ChatMessage(role="user", content="retained question") + answer = ChatMessage(role="assistant", content="OK") + for i, history in enumerate(([u1], [u1, answer, u2])): + state.record_assistant_turn( + session_id="s", + request_messages=history, + content="OK", + tool_calls=None, + generated_token_ids=[100 + i], + prior_turns=i, + ) + unchanged = [u1, answer, u2, answer, ChatMessage(role="user", content="continue")] + warm = state.build_prompt_input( + session_id="s", + messages=unchanged, + rendered_prompt=template.render(unchanged), + tools=None, + template_kwargs=None, + ) + assert [segment["ids"] for segment in warm.segments if "ids" in segment] == [ + [100], + [101], + ] + messages = [u2, answer, ChatMessage(role="user", content="continue")] + rendered = template.render(messages) + prompt = state.build_prompt_input( + session_id="s", + messages=messages, + rendered_prompt=rendered, + tools=None, + template_kwargs=None, + ) + assert prompt.text == rendered + assert prompt.segments is None + assert not state._turns["s"] + + +@pytest.mark.parametrize("changed_role", ["system", "developer", "user", "tool"]) +def test_changed_preceding_context_invalidates_stored_reply(changed_role): + template = ChatTemplate(allow_fallback=True) + state = OpenAITranscriptState(template) + history = [ChatMessage(role=changed_role, content="before")] + state.record_assistant_turn( + session_id="s", + request_messages=history, + content="OK", + tool_calls=None, + generated_token_ids=[123], + prior_turns=0, + ) + unchanged = [*history, ChatMessage(role="assistant", content="OK")] + warm = state.build_prompt_input( + session_id="s", + messages=unchanged, + rendered_prompt=template.render(unchanged), + tools=None, + template_kwargs=None, + ) + assert any(segment.get("ids") == [123] for segment in warm.segments) + messages = [ + ChatMessage(role=changed_role, content="after"), + ChatMessage(role="assistant", content="OK"), + ] + prompt = state.build_prompt_input( + session_id="s", + messages=messages, + rendered_prompt=template.render(messages), + tools=None, + template_kwargs=None, + ) + assert prompt.segments is None + + +@pytest.mark.parametrize("runtime_lease", [False, True]) +def test_idle_eviction_protects_active_transactions(make_client, runtime_lease): + from executorch.examples.llm_server.python.protocol import ChatCompletionRequest + + client, worker = make_client(max_named_sessions=2, evict_idle_sessions=True) + serving = client.app.state.serving + + async def scenario(): + def request(sid): + return ChatCompletionRequest( + messages=[ChatMessage(role="user", content="hi")], session_id=sid + ) + + await serving.create(request("a")) + await serving.create(request("b")) + locks = ( + serving._runtime._session_locks if runtime_lease else serving._transactions + ) + async with locks.hold("a"): + await serving.create(request("c")) + assert worker.open_named == {"a", "c"} + async with locks.hold("c"): + assert not await serving._evict_idle_session() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("before_string", [False, True]) +@pytest.mark.parametrize("after_string", [False, True]) +def test_cache_control_metadata_does_not_invalidate_replay(before_string, after_string): + template = ChatTemplate(allow_fallback=True) + state = OpenAITranscriptState(template) + before = [ + ChatMessage( + role="user", + content=( + "hi" + if before_string + else [ + { + "type": "text", + "text": "hi", + "cache_control": {"type": "ephemeral"}, + } + ] + ), + ) + ] + state.record_assistant_turn( + session_id="s", + request_messages=before, + content="OK", + tool_calls=None, + generated_token_ids=[123], + prior_turns=0, + ) + after = [ + ChatMessage( + role="user", + content="hi" if after_string else [{"type": "text", "text": "hi"}], + ), + ChatMessage(role="assistant", content="OK"), + ] + result = state.build_prompt_input( + session_id="s", + messages=after, + rendered_prompt=template.render(after), + tools=None, + template_kwargs=None, + ) + assert any(segment.get("ids") == [123] for segment in result.segments) + + +@pytest.mark.parametrize("streaming", [False, True]) +def test_task_cancellation_during_native_eviction_never_restarts(streaming): + import threading + + from executorch.examples.llm_server.python.protocol import ChatCompletionRequest + from executorch.examples.llm_server.python.serving_chat import ServingChat + from executorch.examples.llm_server.python.session_runtime import SessionRuntime + from executorch.examples.llm_server.python.tests.test_openrouter_recovery import ( + CapacityWorker, + ) + + class Worker(CapacityWorker): + def __init__(self): + super().__init__(True) + self.closing = threading.Event() + self.release_close = threading.Event() + + def close_session(self, sid): + if sid == "a": + self.closing.set() + assert self.release_close.wait(5) + return super().close_session(sid) + + async def scenario(): + worker = Worker() + runtime = SessionRuntime(worker) + serving = ServingChat( + runtime, + ChatTemplate(allow_fallback=True), + "test", + evict_idle_sessions=True, + streaming_preflight_timeout=0.001, + ) + + def request(sid, stream=False): + return ChatCompletionRequest( + messages=[ChatMessage(role="user", content="hi")], + session_id=sid, + stream=stream, + ) + + async def consume(): + response = await serving.create(request("c", streaming)) + if streaming: + async with aclosing(response): + async for _ in response: + pass + + try: + for sid in ("a", "b"): + await serving.create(request(sid)) + task = asyncio.create_task(consume()) + worker.admission.set() + assert await asyncio.to_thread(worker.closing.wait, 2) + task.cancel() + worker.release_close.set() + with pytest.raises(asyncio.CancelledError): + await task + assert worker.attempts.count("c") == 1 + assert "c" not in worker.sessions + await serving.create(request("d")) + await serving.create(request("e")) + assert worker.sessions == {"d", "e"} + finally: + worker.release_close.set() + worker.admission.set() + await runtime.aclose_worker() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("streaming", [False, True]) +def test_task_cancellation_during_refused_generation_close(monkeypatch, streaming): + from executorch.examples.llm_server.python.protocol import ChatCompletionRequest + from executorch.examples.llm_server.python.serving_chat import ServingChat + from executorch.examples.llm_server.python.session_runtime import ( + Generation, + SessionRuntime, + ) + from executorch.examples.llm_server.python.tests.test_openrouter_recovery import ( + CapacityWorker, + ) + + async def scenario(): + worker = CapacityWorker(True) + runtime = SessionRuntime(worker) + serving = ServingChat( + runtime, + ChatTemplate(allow_fallback=True), + "test", + evict_idle_sessions=True, + streaming_preflight_timeout=0, + ) + closing, release = asyncio.Event(), asyncio.Event() + original_close = Generation._close + + async def gated_close(generation): + await original_close(generation) + if worker.attempts == ["a", "b", "c"]: + closing.set() + await release.wait() + + monkeypatch.setattr(Generation, "_close", gated_close) + + def request(sid): + return ChatCompletionRequest( + messages=[ChatMessage(role="user", content="hi")], + session_id=sid, + stream=streaming and sid == "c", + ) + + async def consume(): + response = await serving.create(request("c")) + if streaming: + async with aclosing(response): + async for _ in response: + pass + + try: + for sid in ("a", "b"): + await serving.create(request(sid)) + task = asyncio.create_task(consume()) + worker.admission.set() + await asyncio.wait_for(closing.wait(), 2) + task.cancel() + release.set() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(task, 2) + assert worker.attempts == ["a", "b", "c"] + assert worker.closed_sessions == [] + assert worker.sessions == {"a", "b"} + assert not serving._cleanup_sessions + finally: + release.set() + worker.admission.set() + await runtime.aclose_worker() + + asyncio.run(scenario()) + + +def test_local_preadmission_overload_does_not_accumulate_cleanup(): + from executorch.examples.llm_server.python.errors import SessionCapacity + from executorch.examples.llm_server.python.serving_chat import ServingChat + from executorch.examples.llm_server.python.session_runtime import SessionRuntime + from executorch.examples.llm_server.python.tests.test_concurrent_serving import ( + _NativeWorker, + _request, + ) + + async def scenario(): + worker = _NativeWorker(tokens=()) + runtime = SessionRuntime(worker, max_concurrent_requests=1) + serving = ServingChat( + runtime, + ChatTemplate(allow_fallback=True), + "test-model", + evict_idle_sessions=True, + ) + try: + async with runtime._operation("busy"): + for i in range(20): + with pytest.raises(SessionCapacity): + await serving.create(_request(f"refused-{i}")) + assert not serving._cleanup_sessions + assert worker.calls.empty() + finally: + await runtime.aclose_worker() + + asyncio.run(scenario()) + + +def test_pending_cleanup_does_not_wait_for_unsettled_native_prefill(): + from executorch.examples.llm_server.python.serving_chat import ServingChat + from executorch.examples.llm_server.python.session_runtime import SessionRuntime + from executorch.examples.llm_server.python.tests.test_concurrent_serving import ( + _call, + _NativeWorker, + _request, + ) + + async def scenario(): + worker = _NativeWorker(tokens=(), cooperative=False) + runtime = SessionRuntime(worker, cancel_grace_seconds=0.01) + serving = ServingChat( + runtime, + ChatTemplate(allow_fallback=True), + "test-model", + evict_idle_sessions=True, + streaming_preflight_timeout=0, + ) + closing = None + request_id = None + try: + stream = await serving.create(_request("s", stream=True)) + request_id = (await _call(worker))[1] + closing = asyncio.create_task(stream.aclose()) + done, _ = await asyncio.wait([closing], timeout=0.5) + assert closing in done, "teardown waited for unsettled native prefill" + await closing + assert runtime.session_in_use("s") + assert list(serving._cleanup_sessions) == ["s"] + assert not serving._transactions.in_use("s") + assert worker.calls.empty() + assert not await serving._evict_idle_session() + worker.finish(request_id) + request_id = None + await asyncio.gather(*runtime._settlements) + assert await serving._evict_idle_session() + assert await _call(worker) == ("close", "s") + assert not serving._cleanup_sessions + finally: + if request_id is not None: + worker.finish(request_id) + if closing is not None: + await closing + await runtime.aclose_worker() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("stream", [False, True]) +def test_partial_decode_error_preserves_admitted_session(stream): + from executorch.examples.llm_server.python.errors import APIError + from executorch.examples.llm_server.python.protocol import ChatCompletionRequest + from executorch.examples.llm_server.python.serving_chat import ServingChat + from executorch.examples.llm_server.python.session_runtime import SessionRuntime + from executorch.examples.llm_server.python.tests.test_concurrent_serving import ( + _NativeWorker, + ) + + async def scenario(): + worker = _NativeWorker( + tokens=("partial",), errors={"generate": ("decode failed", "internal")} + ) + runtime = SessionRuntime(worker) + serving = ServingChat( + runtime, ChatTemplate(allow_fallback=True), "test", evict_idle_sessions=True + ) + try: + req = ChatCompletionRequest( + messages=[{"role": "user", "content": "hi"}], + session_id="new", + stream=stream, + ) + if stream: + result = await serving.create(req) + output = "".join([chunk async for chunk in result]) + assert '"error"' in output + else: + with pytest.raises(APIError): + await serving.create(req) + assert list(serving._idle_sessions) == ["new"] + assert not serving._cleanup_sessions + calls = [] + while not worker.calls.empty(): + calls.append(worker.calls.get_nowait()[0]) + assert calls == ["generate"] # Neither response mode closes the session. + finally: + await runtime.aclose_worker() + + asyncio.run(asyncio.wait_for(scenario(), 5)) diff --git a/examples/llm_server/python/tests/test_sessions.py b/examples/llm_server/python/tests/test_sessions.py index c0d792d4511..12ac33cbbf2 100644 --- a/examples/llm_server/python/tests/test_sessions.py +++ b/examples/llm_server/python/tests/test_sessions.py @@ -266,6 +266,7 @@ def test_worker_op_failure_keeps_transcript(op): template = ChatTemplate(hf_tokenizer_path=None, allow_fallback=True) serving = ServingChat(_RaisingRuntime(), template, "test-model") serving._transcript.record_assistant_turn( + request_messages=[], session_id="s", content="hi", tool_calls=None, @@ -290,9 +291,11 @@ def test_record_assistant_turn_replaces_stale_at_position(): from executorch.examples.llm_server.python.openai_transcript import ( OpenAITranscriptState, ) + from executorch.examples.llm_server.python.protocol import ChatMessage t = OpenAITranscriptState(ChatTemplate(hf_tokenizer_path=None, allow_fallback=True)) t.record_assistant_turn( + request_messages=[], session_id="s", content="a0", tool_calls=None, @@ -300,6 +303,7 @@ def test_record_assistant_turn_replaces_stale_at_position(): prior_turns=0, ) t.record_assistant_turn( + request_messages=[ChatMessage(role="assistant", content="a0")], session_id="s", content="a1", tool_calls=None, @@ -309,6 +313,7 @@ def test_record_assistant_turn_replaces_stale_at_position(): assert [r["ids"] for r in t._turns["s"].values()] == [[1], [2]] # regenerate turn 2 (same prior_turns) -> replaces stale [2], no stale tail t.record_assistant_turn( + request_messages=[ChatMessage(role="assistant", content="a0")], session_id="s", content="a1b", tool_calls=None, @@ -331,6 +336,7 @@ def test_divergence_truncates_stale_tail(): t = OpenAITranscriptState(ChatTemplate(hf_tokenizer_path=None, allow_fallback=True)) t.record_assistant_turn( session_id="s", + request_messages=[ChatMessage(role="user", content="u0")], content="a0", tool_calls=None, generated_token_ids=[1], @@ -338,11 +344,27 @@ def test_divergence_truncates_stale_tail(): ) t.record_assistant_turn( session_id="s", + request_messages=[ + ChatMessage(role="user", content="u0"), + ChatMessage(role="assistant", content="a0"), + ChatMessage(role="user", content="u1"), + ], content="a1", tool_calls=None, generated_token_ids=[2], prior_turns=1, ) + unchanged = [ + ChatMessage(role="user", content="u0"), + ChatMessage(role="assistant", content="a0"), + ] + assert t.build_prompt_input( + session_id="s", + messages=unchanged, + rendered_prompt=t._template.render(unchanged), + tools=None, + template_kwargs=None, + ).segments msgs = [ ChatMessage(role="user", content="u0"), ChatMessage(role="assistant", content="a0-EDITED"), @@ -417,11 +439,23 @@ def test_injected_assistant_exemplar_falls_back_to_text(): t = OpenAITranscriptState(ChatTemplate(hf_tokenizer_path=None, allow_fallback=True)) t.record_assistant_turn( session_id="s", + request_messages=[ChatMessage(role="user", content="u0")], content="a0", tool_calls=None, generated_token_ids=[1, 2], prior_turns=0, ) + unchanged = [ + ChatMessage(role="user", content="u0"), + ChatMessage(role="assistant", content="a0"), + ] + assert t.build_prompt_input( + session_id="s", + messages=unchanged, + rendered_prompt=t._template.render(unchanged), + tools=None, + template_kwargs=None, + ).segments msgs = [ ChatMessage(role="user", content="u0"), ChatMessage(role="assistant", content="INJECTED EXEMPLAR"), # not ours diff --git a/examples/llm_server/python/tests/test_warm_resume_scaffold.py b/examples/llm_server/python/tests/test_warm_resume_scaffold.py index 7583d887416..bdfb6959b45 100644 --- a/examples/llm_server/python/tests/test_warm_resume_scaffold.py +++ b/examples/llm_server/python/tests/test_warm_resume_scaffold.py @@ -18,7 +18,6 @@ import os import pytest - from executorch.examples.llm_server.python.chat_template import ChatTemplate from executorch.examples.llm_server.python.openai_transcript import ( OpenAITranscriptState, @@ -147,6 +146,7 @@ def _scaffold_before(segs, ids): def test_nothink_ordinary_append_inserts_scaffold(): st = OpenAITranscriptState(_FakeQwen(default_thinking=False)) st.record_assistant_turn( + request_messages=_msgs(("user", "u1")), session_id="s", content="a1", tool_calls=None, @@ -172,6 +172,7 @@ def test_nothink_ordinary_append_inserts_scaffold(): def test_think_ordinary_append_inserts_open_scaffold(): st = OpenAITranscriptState(_FakeQwen(default_thinking=True)) st.record_assistant_turn( + request_messages=_msgs(("user", "u1")), session_id="s", content="a1", tool_calls=None, @@ -199,6 +200,7 @@ def test_think_toolloop_normalizes_preserved_scaffold(): # (wrong scaffold) and not append a second one (double-insert). st = OpenAITranscriptState(_FakeQwen(default_thinking=True)) st.record_assistant_turn( + request_messages=_msgs(("user", "u1")), session_id="s", content="a1", tool_calls=None, @@ -224,6 +226,7 @@ def test_think_toolloop_normalizes_preserved_scaffold(): def test_no_scaffold_template_is_unchanged(): st = OpenAITranscriptState(_FakePlain()) st.record_assistant_turn( + request_messages=_msgs(("user", "u1")), session_id="s", content="a1", tool_calls=None, @@ -251,6 +254,7 @@ def test_non_qwen_header_no_scaffold_still_splices(): gen_ids = enc("a1") resident = enc(fake.render(_msgs(("user", "u1")))) + gen_ids st.record_assistant_turn( + request_messages=_msgs(("user", "u1")), session_id="s", content="a1", tool_calls=None, @@ -276,6 +280,7 @@ def test_non_qwen_header_no_scaffold_still_splices(): def test_custom_assistant_header_inserts_scaffold(): st = OpenAITranscriptState(_FakeGemma()) st.record_assistant_turn( + request_messages=_msgs(("user", "u1")), session_id="s", content="a1", tool_calls=None, @@ -299,6 +304,7 @@ def test_custom_assistant_header_inserts_scaffold(): def test_stop_trimmed_turn_falls_back_to_text(): st = OpenAITranscriptState(_FakeQwen()) st.record_assistant_turn( + request_messages=_msgs(("user", "u1")), session_id="s", content="a1", tool_calls=None, @@ -322,6 +328,7 @@ def test_stop_trimmed_turn_falls_back_to_text(): def test_fingerprint_mismatch_falls_back_to_text(): st = OpenAITranscriptState(_FakeQwen()) st.record_assistant_turn( + request_messages=_msgs(("user", "u1")), session_id="s", content="a1", tool_calls=None, @@ -345,6 +352,7 @@ def test_fingerprint_mismatch_falls_back_to_text(): def test_mode_switch_uses_per_turn_scaffold(): st = OpenAITranscriptState(_FakeQwen()) st.record_assistant_turn( + request_messages=_msgs(("user", "u1")), session_id="s", content="a1", tool_calls=None, @@ -353,6 +361,7 @@ def test_mode_switch_uses_per_turn_scaffold(): preamble=NOTHINK, # turn 1 generated no-think ) st.record_assistant_turn( + request_messages=_msgs(("user", "u1"), ("assistant", "a1"), ("user", "u2")), session_id="s", content="a2", tool_calls=None, @@ -422,6 +431,7 @@ def test_tool_turn_splices_despite_reserialized_args(): # fingerprint-match and splice -- not prune to a text fallback. st = OpenAITranscriptState(_FakeQwen()) st.record_assistant_turn( + request_messages=_msgs(("user", "u1")), session_id="s", content=None, tool_calls=[ @@ -478,6 +488,7 @@ def test_gemma_tool_span_ignores_close_marker_inside_string(header): ), ) st.record_assistant_turn( + request_messages=_msgs(("user", "u1")), session_id="s", content="", tool_calls=[call], @@ -584,6 +595,7 @@ def test_gemma_real_template_warm_resume_prefix_with_bos_prefix(): first_prompt = tmpl.render(_msgs(("user", "u1"))) resident = [tok.bos_token_id] + enc(first_prompt) + gen_ids st.record_assistant_turn( + request_messages=_msgs(("user", "u1")), session_id="s", content=content, tool_calls=None, @@ -631,6 +643,7 @@ def test_gemma_real_template_post_tool_splices_call_and_keeps_tool_response(): first_prompt = tmpl.render(_msgs(("user", "Run echo hello42")), tools=tools) resident = [tok.bos_token_id] + enc(first_prompt) + gen_ids st.record_assistant_turn( + request_messages=_msgs(("user", "Run echo hello42")), session_id="s", content="", tool_calls=[call], @@ -673,6 +686,7 @@ def test_token_level_exact_prefix_ordinary(thinking): gen_prompt1 = tmpl.render(_msgs(("user", "u1")), template_kwargs=kw) resident = enc(gen_prompt1) + gen_ids st.record_assistant_turn( + request_messages=_msgs(("user", "u1")), session_id="s", content=content, tool_calls=None, @@ -708,6 +722,7 @@ def test_token_level_exact_prefix_toolloop_think(): gen_prompt1 = tmpl.render(_msgs(("user", "u1")), template_kwargs=kw) resident = enc(gen_prompt1) + gen_ids st.record_assistant_turn( + request_messages=_msgs(("user", "u1")), session_id="s", content=content, tool_calls=None, @@ -800,6 +815,7 @@ def test_thinking_turn_splice_reproduces_resident_prefix(): ) resident, gen_ids = _harmony_resident(fake, raw) st.record_assistant_turn( + request_messages=_msgs(("user", "u1")), session_id="s", content="a1", tool_calls=None, @@ -839,6 +855,7 @@ def test_plain_turn_splice_reproduces_resident_prefix(): raw = " to=user<|message|>a1" resident, gen_ids = _harmony_resident(fake, raw) st.record_assistant_turn( + request_messages=_msgs(("user", "u1")), session_id="s", content="a1", tool_calls=None, @@ -900,6 +917,7 @@ def test_reasoning_echo_must_match_when_explicit( ) resident, gen_ids = _harmony_resident(fake, raw) st.record_assistant_turn( + request_messages=_msgs(("user", "u1")), session_id="s", content="a1", tool_calls=None, @@ -951,6 +969,7 @@ def test_reasoning_edit_invalidates_stored_tail_without_later_resurrection(): gen_ids = enc(raw) generated.append(gen_ids) st.record_assistant_turn( + request_messages=msgs + _msgs(("user", f"u{i}")), session_id="s", content=f"a{i}", tool_calls=None, @@ -1022,6 +1041,7 @@ def enc(text): st = OpenAITranscriptState(fake) st.record_assistant_turn( + request_messages=_msgs(("user", "u1")), session_id="s", content="ab", tool_calls=None, @@ -1116,6 +1136,7 @@ def test_user_header_literal_does_not_truncate_conversation(): # instead of deleting KEEP and the role boundary while splicing. st = OpenAITranscriptState(_FakeMismatchedHeader()) st.record_assistant_turn( + request_messages=_msgs(("user", "Explain <|im_start|>assistant\nKEEP")), session_id="s", content="a1", tool_calls=None, @@ -1152,6 +1173,7 @@ def test_splice_requires_verified_header_to_preserve_full_prompt(header): gen_ids = enc(" to=user<|message|>a1") resident = enc(fake.render(_msgs(("user", "plain question")))) + gen_ids st.record_assistant_turn( + request_messages=_msgs(("user", "plain question")), session_id="s", content="a1", tool_calls=None, @@ -1193,6 +1215,7 @@ def test_mistral_bracket_terminator_tail_falls_back(u1): gen_ids = enc("a1") resident = enc(fake.render(_msgs(("user", u1)))) + gen_ids st.record_assistant_turn( + request_messages=_msgs(("user", u1)), session_id="s", content="a1", tool_calls=None, @@ -1247,6 +1270,7 @@ def enc(text): assert generated != enc("ab") resident = enc(first_prompt) + generated state.record_assistant_turn( + request_messages=_msgs(("user", "u1")), session_id="s", content="ab", tool_calls=None, @@ -1355,6 +1379,7 @@ def test_glimmer_real_template_thinking_turn_splice_reproduces_resident(): ) gen_ids = enc(raw) st.record_assistant_turn( + request_messages=_msgs(("user", u1)), session_id="s", content=visible, tool_calls=None, diff --git a/examples/llm_server/python/tests/test_worker_client.py b/examples/llm_server/python/tests/test_worker_client.py index b45d08f64ae..195f93afec1 100644 --- a/examples/llm_server/python/tests/test_worker_client.py +++ b/examples/llm_server/python/tests/test_worker_client.py @@ -230,9 +230,11 @@ def test_op_non_json_raises_worker_error(): WorkerClient(proc).open_session("abc") -def test_close_session_sends_op_and_acks(): - proc = _FakeProc(_lines({"closed": True, "session_id": "abc"})) - WorkerClient(proc).close_session("abc") +@pytest.mark.parametrize("existed", [None, False, True]) +def test_close_session_sends_op_and_acks(existed): + metadata = {} if existed is None else {"existed": existed} + proc = _FakeProc(_lines({"closed": True, "session_id": "abc", **metadata})) + assert WorkerClient(proc).close_session("abc") is existed assert json.loads(proc.stdin.written[0]) == {"op": "close", "session_id": "abc"} diff --git a/examples/llm_server/python/worker_client.py b/examples/llm_server/python/worker_client.py index 812cd2cd177..f0af78d5f41 100644 --- a/examples/llm_server/python/worker_client.py +++ b/examples/llm_server/python/worker_client.py @@ -105,10 +105,16 @@ class WorkerError(RuntimeError): """ def __init__( - self, message: str, code: Optional[str] = None, *, context_overflow=None + self, + message: str, + code: Optional[str] = None, + *, + admitted: Optional[bool] = None, + context_overflow=None, ): super().__init__(message) self.code = code + self.admitted = admitted self.context_overflow = context_overflow @@ -382,9 +388,10 @@ def open_session(self, session_id: str) -> None: ("capacity_exhausted" / "unsupported_session") if the worker refuses.""" self._op({"op": "open", "session_id": session_id}, ack_key="opened") - def close_session(self, session_id: str) -> None: - """Destroy a named session, freeing its state (idempotent).""" - self._op({"op": "close", "session_id": session_id}, ack_key="closed") + def close_session(self, session_id: str) -> Optional[bool]: + """Close idempotently; return whether it existed, or None for older workers.""" + result = self._op({"op": "close", "session_id": session_id}, ack_key="closed") + return result.get("existed") def reset_session(self, session_id: str) -> None: """Clear a named session's context (KV/recurrent + resident tokens) but @@ -421,15 +428,23 @@ def _read_message(self) -> dict: except WorkerError as error: raise self._record_failure(error) - def _op(self, request: dict, ack_key: str) -> None: + def _op(self, request: dict, ack_key: str) -> dict: with self._lock: self._ensure_usable() self._write_request(request) msg = self._read_message() if msg.get(ack_key): - return + if "existed" in msg and type(msg["existed"]) is not bool: + raise self._record_failure( + WorkerError("invalid worker existed flag") + ) + return msg if "error" in msg: - raise WorkerError(str(msg["error"]), code=msg.get("code")) + raise WorkerError( + str(msg["error"]), + code=msg.get("code"), + admitted=msg.get("admitted"), + ) raise self._record_failure( WorkerError(f"unexpected worker response: {msg}") ) @@ -465,7 +480,11 @@ def _generate_locked(self, request: dict, token_callback, stats_callback) -> Non self._on_done(msg, stats_callback) return elif "error" in msg: - raise WorkerError(str(msg["error"]), code=msg.get("code")) + raise WorkerError( + str(msg["error"]), + code=msg.get("code"), + admitted=msg.get("admitted"), + ) else: raise self._record_failure( WorkerError(f"unexpected worker response: {msg}") diff --git a/examples/llm_server/spec/README.md b/examples/llm_server/spec/README.md index a75862c3c66..633828ef446 100644 --- a/examples/llm_server/spec/README.md +++ b/examples/llm_server/spec/README.md @@ -200,8 +200,10 @@ request is an exact-token extension of its resident context prefills only the ne suffix. All KV/resident state lives inside the worker/session, never the control plane. -For named sessions, an unchanged assistant reply can reuse its original generated -token IDs. Clients may omit `reasoning_content` or send `null` when echoing a reply; +For named sessions, an unchanged assistant reply with the same preceding message +history can reuse its original generated token IDs. Compaction, removal, or edits +to earlier message content invalidate affected records, even if the assistant's +visible answer is identical. Clients may omit `reasoning_content` or send `null` when echoing a reply; both allow replay of the original tokens, including reasoning. A supplied string must exactly match the value returned to that client. String edits, including whitespace or line-ending changes and `""`, invalidate that turn's stored IDs and @@ -272,13 +274,24 @@ not fail unrelated requests. Optional `generated_token_ids` in a terminal is present only when the runtime can safely replay exact output IDs. Statistics describe consumed prompt and generated tokens, not necessarily visible text. Runtime failures have the terminal shape -`{"request_id":8,"error":"message","code":"internal"}`. Stable error codes +`{"request_id":8,"error":"message","code":"internal"}`. Rejections before +runtime admission additionally include `"admitted":false`; clients must not infer +that a named session was created from such a rejection. An absent `admitted` +field leaves admission unknown. Stable error codes include `invalid_argument`, `not_ready`, `session_not_found`, `session_busy`, -`capacity_exhausted`, `internal`, `slow_consumer`, and `frame_too_large`. +`capacity_exhausted`, `session_capacity_exhausted`, `internal`, `slow_consumer`, and `frame_too_large`. Lifecycle commands are `open`, `close`, and `reset`, each with `session_id` and -its own request ID. Their replies are respectively `opened:true`, `closed:true`, -and `reset:true`, or a correlated error. They do not block the input reader. +its own request ID. These commands do not block the input reader. +Their replies are respectively `opened:true`, `closed:true`, +and `reset:true`, or a correlated error. Close remains idempotent and includes +`existed:true` when it released a logical session slot, or `existed:false` when +the session was already absent. Older workers can omit this field; a client +cannot then distinguish stale candidates from released slots. Older multiplexed +workers also use `capacity_exhausted` for session-slot refusals, so automatic +eviction does not trigger for those workers. The legacy `WorkerClient` maps its +explicit session-open capacity refusal to session-slot exhaustion; its close +reply may still lack existence information. An accepted close/reset fences its session: earlier generation text and terminals precede its ACK, and runtime-owned generation callbacks and captures are cleaned up before the ACK is enqueued. Later accepted same-session operations stay behind @@ -375,3 +388,22 @@ deferred: no compatible local PTE/tokenizer has been confirmed, and this stack must not download/export weights or claim that a real combined forward was verified. A Glimmer `embed_text`/`forward_from_embeddings` last-logits artifact is not assumed compatible with this packed construction path. + +Idle-session recovery retries at most once and only for native +`session_capacity_exhausted` (logical session slots), or a legacy named-session +open refusal. Request, control-queue, executor, and output-buffer overload remain +`capacity_exhausted` and never trigger eviction. Both map to HTTP 429. Failed +closes retain replay state and rotate the victim for a later request. + +Unconfirmed native admissions whose cleanup close fails remain cleanup candidates +until closure is confirmed. Updated workers report whether close released a slot, +so stale candidates do not consume the admission retry. Native front-door and +local request-limit rejections carry `admitted:false`; they cannot have created +a session and do not need a cleanup close. Other errors without output can leave +session state, so they still require cleanup. Eviction skips sessions whose +runtime lease is still settling, and disconnected consumers defer their cleanup +rather than waiting for that lease. Task cancellation prevents an admission retry, +even when it arrives during generation cleanup. + +Client-only `cache_control` markers do not change the replay history fingerprint. +A string and its equivalent single text-part content are also interchangeable. diff --git a/examples/models/muse-glimmer/BATCHING_SERVING.md b/examples/models/muse-glimmer/BATCHING_SERVING.md index db784b0c852..769fcead2bd 100644 --- a/examples/models/muse-glimmer/BATCHING_SERVING.md +++ b/examples/models/muse-glimmer/BATCHING_SERVING.md @@ -109,9 +109,10 @@ The launcher waits for initial generation readiness before returning streaming headers. Set `--streaming-preflight-timeout 1` to bound generation-readiness waiting, including prefill, to one second. This does not bound earlier request preparation or waiting behind another request on the same session. -Idle streams send SSE keepalive comments every 15 -seconds. Admission errors detected before headers retain their HTTP status; late -errors are delivered as SSE errors. Keepalives indicate a live connection, not +Idle streams send SSE keepalive comments every 15 seconds. Admission errors +detected before headers retain their HTTP status; later session-slot rejection +before any raw model output can evict an idle session and retry, including after +headers. Other late errors are delivered as SSE errors. Keepalives indicate a live connection, not measured prefill progress. ### OpenRouter compatibility @@ -176,6 +177,18 @@ apply. Text/image prefix snapshots are opt-in via `--prefix-cache-entries` ## Session Behavior +- Named sessions persist by default. `--evict-idle-sessions` allows the server to + close the least recently used idle session when logical session slots are exhausted, clearing + its transcript cache as well. Active requests and queued users of a session are + protected. Resubmitting an evicted session with full history recreates it. + This opt-in policy also allows anonymous requests to evict named idle sessions; + the anonymous request releases its temporary slot when it finishes. If all + slots are active, capacity errors still apply. Request/queue overload does not + trigger eviction. Streaming and non-streaming requests both confirm a new + named session on the first raw output or successful terminal response. +- Generated-token replay is bound to preceding history as well as the assistant + response. Compaction or edited earlier messages invalidate affected records, + preventing an identical visible answer from restoring another turn's reasoning. - Text and image prompts use the shared prefix-reuse path. Image rows count as decoder positions; matching uses token IDs and an image-content digest with content-relative row offsets, not patch token IDs alone. diff --git a/examples/models/muse-glimmer/serving/serve.py b/examples/models/muse-glimmer/serving/serve.py index b3097dc21f2..8f862c91afc 100644 --- a/examples/models/muse-glimmer/serving/serve.py +++ b/examples/models/muse-glimmer/serving/serve.py @@ -257,7 +257,7 @@ def _count_prompt_tokens(self, prompt: PromptInput) -> int | None: return None return super()._count_prompt_tokens(prompt) - async def _create(self, req: ChatCompletionRequest, lease): + async def _create(self, req: ChatCompletionRequest, lease, admission): # The inherited create() owns the transaction around preparation and # response cleanup; only image-aware prompt construction differs here. if req.model is not None and req.model != self._model_id: @@ -267,6 +267,7 @@ async def _create(self, req: ChatCompletionRequest, lease): if req.session_id is not None: self._validate_session_id(req.session_id) + history_req = req req, image_segment = _normalize_image_request(req, self._max_image_bytes) template_tools = None if req.tool_choice == "none" else req.tools template_kwargs = dict(req.chat_template_kwargs or {}) @@ -280,6 +281,7 @@ async def _create(self, req: ChatCompletionRequest, lease): rendered_prompt=prompt, tools=template_tools, template_kwargs=template_kwargs, + history_messages=history_req.messages, ) prompt_input = self._with_image(prompt_input, image_segment) if self._max_context: @@ -297,7 +299,7 @@ async def _create(self, req: ChatCompletionRequest, lease): template_kwargs, tools=template_tools ) return await self._create_response( - req, prompt_input, options, preamble, gen_stops, lease + history_req, prompt_input, options, preamble, gen_stops, lease, admission ) diff --git a/examples/models/muse-glimmer/serving/serve_batching.py b/examples/models/muse-glimmer/serving/serve_batching.py index b96061a98ef..c0f2a752c8a 100644 --- a/examples/models/muse-glimmer/serving/serve_batching.py +++ b/examples/models/muse-glimmer/serving/serve_batching.py @@ -62,6 +62,12 @@ def _parse_args(argv=None): parser.add_argument("--model-id", default="muse_glimmer") parser.add_argument("--max-context", type=int, required=True) parser.add_argument("--max-sessions", type=int, default=4) + parser.add_argument( + "--evict-idle-sessions", + action="store_true", + help="Evict least recently used idle sessions when logical session slots are exhausted; " + "clients must send complete history.", + ) parser.add_argument("--max-decode-sequences", type=int, default=4) parser.add_argument("--max-inflight-requests", type=int, default=4) parser.add_argument( @@ -224,6 +230,7 @@ async def serving_factory(): template, args.model_id, max_context=args.max_context, + evict_idle_sessions=args.evict_idle_sessions, streaming_preflight_timeout=args.streaming_preflight_timeout, api_dialect=args.api_dialect, max_image_bytes=args.max_image_bytes, diff --git a/examples/models/muse-glimmer/tests/test_serve.py b/examples/models/muse-glimmer/tests/test_serve.py index 5241a04cc10..3ed80c7dafc 100644 --- a/examples/models/muse-glimmer/tests/test_serve.py +++ b/examples/models/muse-glimmer/tests/test_serve.py @@ -987,8 +987,8 @@ async def conversation(): assert _assemble(prompt) == _benc(template.render(messages)) expected = generated[:i] if i < 3 else [generated[0]] if i == 4: - expected.append(generated[3]) if not restore_history: + expected.append(generated[3]) resident = _assemble(runtime.prompts[3]) + generated[3] assert _assemble(prompt)[: len(resident)] == resident assert [s["ids"] for s in prompt.segments or [] if "ids" in s] == expected @@ -1150,3 +1150,73 @@ async def generate_stream(self, session_id, prompt, options, stats): assert error["metadata"]["completion_tokens"] == completion else: assert "metadata" not in error + + +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize("context_kind", ["image", "external_reasoning"]) +@pytest.mark.parametrize("changed", [False, True]) +def test_changed_model_context_cannot_restore_old_generated_tokens( + stream, context_kind, changed +): + async def scenario(): + def history(value): + if context_kind == "image": + return [ + ChatMessage( + role="user", + content=[ + {"type": "text", "text": "Describe this image"}, + { + "type": "image_url", + "image_url": { + "url": "data:image/png;base64," + + base64.b64encode(value.encode()).decode() + }, + }, + ], + ) + ] + return [ + ChatMessage(role="user", content="Earlier question"), + ChatMessage( + role="assistant", content="Earlier answer", reasoning_content=value + ), + ChatMessage(role="user", content="Continue"), + ] + + serving, runtime = _glimmer_serving( + [ + { + "text": " to=self<|message|>Old context.<|eom|><|start|>assistant to=user<|message|>OK", + "gen_ids": [777], + }, + {"text": " to=user<|message|>Fresh answer"}, + ], + template=_HarmonyTemplate(), + ) + answer = await _create_message( + serving, + ChatCompletionRequest( + messages=history("before"), + session_id="s", + stream=stream, + ), + ) + assert answer["content"] == "OK" + await _create_message( + serving, + ChatCompletionRequest( + messages=history("after" if changed else "before") + + [ + ChatMessage(role="assistant", content="OK"), + ChatMessage(role="user", content="Next"), + ], + session_id="s", + stream=stream, + ), + ) + assert any( + segment.get("ids") == [777] for segment in runtime.prompts[1].segments or [] + ) == (not changed) + + asyncio.run(scenario()) diff --git a/extension/llm/serving/serving_runtime.cpp b/extension/llm/serving/serving_runtime.cpp index 17e2a974921..d53e49b2310 100644 --- a/extension/llm/serving/serving_runtime.cpp +++ b/extension/llm/serving/serving_runtime.cpp @@ -235,15 +235,18 @@ struct ServingRuntime::Impl { bool dirty = false; }; + using Completion = std::function; + struct Command { Operation operation; std::string key; // Queue transfers must not destroy user captures under mutex_. - std::unique_ptr completion; + std::unique_ptr completion; Request request; RequestId fence_through = 0; bool processed = false; LifecycleResult result = std::nullopt; + bool existed = false; }; Impl( @@ -294,15 +297,16 @@ struct ServingRuntime::Impl { } static void complete( - std::unique_ptr completion, - LifecycleResult result) { + std::unique_ptr completion, + LifecycleResult result, + bool existed = false) { if (!completion || !*completion) { return; } #if ET_HAS_EXCEPTIONS try { #endif - (*completion)(std::move(result)); + (*completion)(std::move(result), existed); #if ET_HAS_EXCEPTIONS } catch (...) { ET_LOG(Error, "Lifecycle completion callback threw"); @@ -324,10 +328,20 @@ struct ServingRuntime::Impl { void submit(Operation operation, std::string key, LifecycleCallback on_complete) { + submit( + operation, + std::move(key), + [callback = std::move(on_complete)](LifecycleResult result, bool) { + if (callback) + callback(std::move(result)); + }); + } + + void submit(Operation operation, std::string key, Completion on_complete) { Command command{ operation, std::move(key), - std::make_unique(std::move(on_complete)), + std::make_unique(std::move(on_complete)), {}}; LifecycleResult error; { @@ -369,6 +383,7 @@ struct ServingRuntime::Impl { } auto it = sessions_.find(command.key); if (command.operation == Operation::Close) { + command.existed = it != sessions_.end(); if (it != sessions_.end()) { if (it->second.active_request) { cancelled = requests_.at(it->second.active_request); @@ -396,7 +411,7 @@ struct ServingRuntime::Impl { if (opening) { if (sessions_.size() == config_.max_sessions) { return ServingError{ - ErrorCode::CapacityExceeded, "session limit reached"}; + ErrorCode::SessionCapacityExceeded, "session limit reached"}; } it = sessions_.try_emplace(command.key).first; it->second.incarnation = next_incarnation_++; @@ -1064,8 +1079,8 @@ struct ServingRuntime::Impl { if (opening) { if (sessions_.size() == config_.max_sessions) { lock.unlock(); - request->reject( - ServingError{ErrorCode::CapacityExceeded, "session limit reached"}); + request->reject(ServingError{ + ErrorCode::SessionCapacityExceeded, "session limit reached"}); return; } it = sessions_.try_emplace(key).first; @@ -1617,7 +1632,10 @@ struct ServingRuntime::Impl { ServingError{ErrorCode::NotReady, "runtime is stopping"}; } } - complete(std::move(command.completion), std::move(command.result)); + complete( + std::move(command.completion), + std::move(command.result), + command.existed); } decltype(sessions_) retired; { @@ -1846,6 +1864,18 @@ void ServingRuntime::close_session_async( impl_->submit(Impl::Operation::Close, std::move(key), std::move(on_complete)); } +void ServingRuntime::close_session_with_result_async( + std::string key, + CloseCallback on_complete) { + impl_->submit( + Impl::Operation::Close, + std::move(key), + [callback = std::move(on_complete)](LifecycleResult error, bool existed) { + if (callback) + callback(CloseResult{std::move(error), existed}); + }); +} + std::future ServingRuntime::close_session_async( std::string key) { return impl_->submit(Impl::Operation::Close, std::move(key)); diff --git a/extension/llm/serving/serving_runtime.h b/extension/llm/serving/serving_runtime.h index 907d1d8db5a..9a7721725b0 100644 --- a/extension/llm/serving/serving_runtime.h +++ b/extension/llm/serving/serving_runtime.h @@ -96,6 +96,12 @@ struct ET_EXPERIMENTAL ServingRuntimeConfig { // operation. using LifecycleResult ET_EXPERIMENTAL = std::optional; using LifecycleCallback ET_EXPERIMENTAL = std::function; +struct ET_EXPERIMENTAL CloseResult { + LifecycleResult error; + // Meaningful on success: false means the session was already absent. + bool existed = false; +}; +using CloseCallback ET_EXPERIMENTAL = std::function; using GenerateResult ET_EXPERIMENTAL = std::variant; @@ -156,6 +162,11 @@ class ET_EXPERIMENTAL ServingRuntime { void close_session_async(std::string key, LifecycleCallback on_complete); std::future close_session_async(std::string key); + // Same idempotent close, additionally reporting whether a slot existed. + void close_session_with_result_async( + std::string key, + CloseCallback on_complete); + // Cold replacement, retaining the key and logical slot throughout. Success // means reopening and old-response callback cleanup completed. Failure // destroys the old state and leaves the diff --git a/extension/llm/serving/test/generation_bridge_test.cpp b/extension/llm/serving/test/generation_bridge_test.cpp index bfc8202c9f0..e8074179c7e 100644 --- a/extension/llm/serving/test/generation_bridge_test.cpp +++ b/extension/llm/serving/test/generation_bridge_test.cpp @@ -1608,7 +1608,7 @@ TEST_P(GenerationFenceTest, MissingKeyFenceRetainsPermitsThroughCleanup) { (void)capture; EXPECT_EQ(update.finish_reason, batching::FinishReason::Failed); ASSERT_TRUE(handle.error()); - EXPECT_EQ(handle.error()->code, ErrorCode::CapacityExceeded); + EXPECT_EQ(handle.error()->code, ErrorCode::SessionCapacityExceeded); sink_gate.arrive(); }; auto handle = accepted(GenerationBridge::submit(*runtime, std::move(input))); @@ -1986,7 +1986,7 @@ TEST_F( events->record(update); EXPECT_EQ(update.finish_reason, batching::FinishReason::Failed); ASSERT_TRUE(handle.error()); - EXPECT_EQ(handle.error()->code, ErrorCode::CapacityExceeded); + EXPECT_EQ(handle.error()->code, ErrorCode::SessionCapacityExceeded); sink_gate.arrive(); throw 1; }; @@ -1994,7 +1994,7 @@ TEST_F( ASSERT_TRUE(sink_gate.wait_for()); const auto error = handle.error(); ASSERT_TRUE(error); - EXPECT_EQ(error->code, ErrorCode::CapacityExceeded); + EXPECT_EQ(error->code, ErrorCode::SessionCapacityExceeded); handle.cancel(); auto stopped = std::make_shared>(false); callback_callers.emplace_back([this, stopped] { diff --git a/extension/llm/serving/test/serving_runtime_test.cpp b/extension/llm/serving/test/serving_runtime_test.cpp index de2df511d81..a5922ad9582 100644 --- a/extension/llm/serving/test/serving_runtime_test.cpp +++ b/extension/llm/serving/test/serving_runtime_test.cpp @@ -291,6 +291,26 @@ TEST_F(ServingRuntimeTest, DuplicateOpenAndCloseAreIdempotent) { EXPECT_EQ(executor.closed(), executor.opened()); } +TEST_F(ServingRuntimeTest, CloseReportsExistenceWithoutFailingAbsentSessions) { + start(); + EXPECT_FALSE(result(runtime->open_session_async("session"))); + for (bool existed : {true, false}) { + std::promise completion; + auto future = completion.get_future(); + runtime->close_session_with_result_async( + "session", [&completion](auto closed) { + completion.set_value(std::move(closed)); + }); + ASSERT_EQ( + future.wait_for(std::chrono::seconds(5)), std::future_status::ready); + auto closed = future.get(); + EXPECT_FALSE(closed.error); + EXPECT_EQ(closed.existed, existed); + EXPECT_EQ(runtime->info().active_sessions, 0u); + } + EXPECT_FALSE(result(runtime->close_session_async("session"))); +} + TEST_F(ServingRuntimeTest, SameKeyOperationsPreserveAdmissionOrder) { executor.opening.hold(); executor.capacity = 1; @@ -349,7 +369,7 @@ TEST_F(ServingRuntimeTest, LogicalCapacityRejectsWithoutCallingExecutor) { auto second = runtime->open_session_async("second"); executor.opening.release(); EXPECT_FALSE(result(std::move(first))); - expect_error(std::move(second), ErrorCode::CapacityExceeded); + expect_error(std::move(second), ErrorCode::SessionCapacityExceeded); EXPECT_EQ(executor.open_attempts.load(), 1); EXPECT_FALSE(result(runtime->close_session_async("first"))); EXPECT_FALSE(result(runtime->open_session_async("second"))); @@ -389,7 +409,7 @@ TEST_F(ServingRuntimeTest, ResetRetainsSlotUntilColdReplacementCompletes) { reset.wait_for(std::chrono::seconds(0)), std::future_status::timeout); executor.opening.release(); EXPECT_FALSE(result(std::move(reset))); - expect_error(std::move(other), ErrorCode::CapacityExceeded); + expect_error(std::move(other), ErrorCode::SessionCapacityExceeded); ASSERT_EQ(executor.opened().size(), 2u); EXPECT_NE(executor.opened().back(), original); EXPECT_EQ(executor.open_count(), 1); @@ -410,7 +430,7 @@ TEST_F(ServingRuntimeTest, FailedResetRetainsUnavailableSlotAndCanRetry) { EXPECT_EQ(executor.closed().size(), 1u); expect_error(runtime->open_session_async("session"), ErrorCode::NotReady); expect_error( - runtime->open_session_async("other"), ErrorCode::CapacityExceeded); + runtime->open_session_async("other"), ErrorCode::SessionCapacityExceeded); EXPECT_EQ(executor.open_attempts.load(), 2); EXPECT_FALSE(result(runtime->reset_session_async("session"))); EXPECT_EQ(executor.open_attempts.load(), 3); diff --git a/extension/llm/serving/types.h b/extension/llm/serving/types.h index 165dd9ac98a..1ff9366a9f4 100644 --- a/extension/llm/serving/types.h +++ b/extension/llm/serving/types.h @@ -76,6 +76,7 @@ enum class ET_EXPERIMENTAL ErrorCode { SessionBusy, CapacityExceeded, Internal, + SessionCapacityExceeded, ContextLengthExceeded, }; From 5f0e9451dcf693160188fec18bfb5b8381228857 Mon Sep 17 00:00:00 2001 From: George Gekov Date: Thu, 5 Nov 2026 22:10:38 +0000 Subject: [PATCH 106/128] Arm backend: Update IFNetTwoBlock lowering for explicit TOSA operands (#24665) For MUL and PAD, the IFNetTwoBlock lowering assumes the shift and padding value are passed as scalar attributes. PR #24287 changed these to explicit tensor inputs. Update the lowering to create tensor inputs for PAD values and recognize MUL's shift as a constant zero tensor. Assisted-by: OpenAI Codex Signed-off-by: George Gekov --- .../rife_vgf/passes/pad_rife_block0_concat.py | 31 +++++++++---- .../QAT_example/rife_vgf/passes/rife_blend.py | 10 ++--- .../passes/test_pad_rife_block0_concat.py | 43 ++++++++++++++----- .../rife_vgf/passes/test_rife_blend.py | 8 ++-- 4 files changed, 63 insertions(+), 29 deletions(-) diff --git a/examples/arm/QAT_example/rife_vgf/passes/pad_rife_block0_concat.py b/examples/arm/QAT_example/rife_vgf/passes/pad_rife_block0_concat.py index 067a027d655..1af4dcffcdb 100644 --- a/examples/arm/QAT_example/rife_vgf/passes/pad_rife_block0_concat.py +++ b/examples/arm/QAT_example/rife_vgf/passes/pad_rife_block0_concat.py @@ -8,6 +8,7 @@ import torch from executorch.backends.arm._passes import ArmPass +from executorch.backends.arm._passes.tosa_lowering_utils import create_const from executorch.backends.arm.tosa.mapping import TosaSpecialDtype from executorch.backends.arm.vgf.shaders.grid_sampler import ( CUSTOM_SHADER_DOMAIN_NAME, @@ -72,7 +73,7 @@ def _ignores_alpha(custom, image): def _reusable_padding(image): for candidate in image.users: if ( - _is_op(candidate, TOSA.PAD.default, 2) + _is_op(candidate, TOSA.PAD.default, 3) and _shape_values(candidate.args[1]) == [0, 0, 0, 0, 0, 0, 0, 1] and all(_ignores_alpha(user, candidate) for user in candidate.users) ): @@ -80,6 +81,15 @@ def _reusable_padding(image): return None +def _move_padding_before_resize(image, resize, positions): + if positions[image] < positions[resize]: + return + padding = image.args[1] + if isinstance(padding, torch.fx.Node) and positions[padding] > positions[resize]: + resize.prepend(padding) + resize.prepend(image) + + class PadRifeBlock0ConcatPass(ArmPass): """Carry block0 timestep and padding through four C4 concat branches.""" @@ -176,6 +186,14 @@ def call(self, graph_module: GraphModule) -> PassResult: for rescale, code in zip(concat.args[0][:2], (timestep, 0)): resize = rescale.args[0] image = _reusable_padding(resize.args[0]) + positions = {node: i for i, node in enumerate(graph.nodes)} + anchor = ( + image + if image is not None and positions[image] < positions[resize] + else resize + ) + with graph.inserting_before(anchor): + pad_value = create_const(graph, code, torch.int8, resize.args[0]) if image is None: with graph.inserting_before(resize): padding = graph.call_function( @@ -186,18 +204,13 @@ def call(self, graph_module: GraphModule) -> PassResult: TosaSpecialDtype.meta_key(): TosaSpecialDtype.SHAPE, } image = graph.call_function( - TOSA.PAD.default, (resize.args[0], padding), {"value": code} + TOSA.PAD.default, (resize.args[0], padding, pad_value) ) image.meta = dict(resize.args[0].meta) else: # The recognized shaders only use RGB; their alpha padding is unused. - image.kwargs = {"value": code} - padding = image.args[1] - if isinstance(padding, torch.fx.Node) and list(graph.nodes).index( - padding - ) > list(graph.nodes).index(resize): - resize.prepend(padding) - resize.prepend(image) + image.args = (*image.args[:2], pad_value) + _move_padding_before_resize(image, resize, positions) resize.args = (image, *resize.args[1:]) # Constant bilinear input becomes 4 * code, then RESCALE divides by 4. for node in (image, resize, rescale): diff --git a/examples/arm/QAT_example/rife_vgf/passes/rife_blend.py b/examples/arm/QAT_example/rife_vgf/passes/rife_blend.py index bb29dec227d..832d9c316bc 100644 --- a/examples/arm/QAT_example/rife_vgf/passes/rife_blend.py +++ b/examples/arm/QAT_example/rife_vgf/passes/rife_blend.py @@ -72,13 +72,13 @@ def _apply_rescale(values, node, *, clamp=True): def _weighted_image(branch, warp, shape, nodes): multiply_rescale = _rescale(branch, torch.int32, nodes) multiply = _rescale(multiply_rescale, torch.int8, nodes) - if ( - not _is_op(multiply, TOSA.MUL.default, 2) - or multiply.kwargs.get("shift", 0) != 0 - ): + if not _is_op(multiply, TOSA.MUL.default, 3): + raise ValueError("Expected an image-mask product with a tensor shift.") + shift = multiply.args[2] + if not _is_op(shift, TOSA.CONST.default, 3) or shift.args != ([0], [1], torch.int8): raise ValueError("Expected an unshifted image-mask product.") nodes.add(multiply) - image_rescale, mask_rescale = multiply.args + image_rescale, mask_rescale = multiply.args[:2] crop = _rescale(image_rescale, torch.int32, nodes) mask = _rescale(mask_rescale, torch.int32, nodes) if any(n.args[2] != [1.0] for n in (image_rescale, mask_rescale)): diff --git a/examples/arm/QAT_example/rife_vgf/passes/test_pad_rife_block0_concat.py b/examples/arm/QAT_example/rife_vgf/passes/test_pad_rife_block0_concat.py index 05176cfb826..55788d2f691 100644 --- a/examples/arm/QAT_example/rife_vgf/passes/test_pad_rife_block0_concat.py +++ b/examples/arm/QAT_example/rife_vgf/passes/test_pad_rife_block0_concat.py @@ -101,7 +101,11 @@ def test_pads_concat_and_preserves_convolution(adapted, zero_point): resize = branch.args[0] image = resize.args[0] assert image.target == TOSA.PAD.default - assert image.kwargs == {"value": code} + assert len(image.args) == 3 and not image.kwargs + assert image.args[2].target == TOSA.CONST.default + assert image.args[2].args == ([code], [1], torch.int8) + assert tuple(image.args[2].meta["val"].shape) == (1,) + assert image.args[2].meta["val"].dtype == torch.int8 assert pad._shape_values(image.args[1]) == [0, 0, 0, 0, 0, 0, 0, 1] assert tuple(image.meta["val"].shape) == (1, 8, 12, 4) assert tuple(resize.meta["val"].shape) == (1, 4, 6, 4) @@ -198,6 +202,10 @@ def test_requires_exported_program(): pad.PadRifeBlock0ConcatPass()(program.graph_module) +@pytest.mark.parametrize( + "pad_before_resize,consumer_before_resize", + [(False, False), (True, False), (True, True)], +) @pytest.mark.parametrize( "consumer", [ @@ -210,16 +218,23 @@ def test_requires_exported_program(): "unknown", ], ) -def test_reuses_padding_only_when_alpha_is_unused(consumer): +def test_reuses_padding_only_when_alpha_is_unused( + consumer, pad_before_resize, consumer_before_resize +): import operator program, inputs, concat, conv = _program() graph = program.graph - with graph.inserting_before(graph.output_node()): + resize = concat.args[0][0].args[0] + with graph.inserting_before(resize if pad_before_resize else graph.output_node()): + pad_value = pad.create_const(graph, -11, torch.int8, inputs[0]) image = graph.call_function( - TOSA.PAD.default, (inputs[0], [0, 0, 0, 0, 0, 0, 0, 1]), {"value": -11} + TOSA.PAD.default, (inputs[0], [0, 0, 0, 0, 0, 0, 0, 1], pad_value) ) image.meta["val"] = inputs[0].meta["val"].new_empty((1, 8, 12, 4)) + with graph.inserting_before( + resize if consumer_before_resize else graph.output_node() + ): name = ( pad.OPERATOR_NAME if consumer == "packed" @@ -245,31 +260,34 @@ def test_reuses_padding_only_when_alpha_is_unused(consumer): shape = [1, channels, 8, 12] crop = graph.call_function(TOSA.SLICE.default, (output, [0, 0, 0, 0], shape)) graph.output_node().args = ((conv, crop),) + graph.lint() assert pad.PadRifeBlock0ConcatPass(program)(program.graph_module).modified padded_input = concat.args[0][0].args[0].args[0] + positions = {node: index for index, node in enumerate(graph.nodes)} + assert positions[image.args[2]] < positions[image] < positions[custom] if consumer in ("packed", "final_blend", "warp_rgb", "warp_rgb_nchw"): assert padded_input is image - assert image.kwargs["value"] == 40 - assert list(graph.nodes).index(image) < list(graph.nodes).index(concat) + assert image.args[2].args == ([40], [1], torch.int8) + assert positions[image] < positions[resize] else: assert padded_input is not image - assert image.kwargs["value"] == -11 + assert image.args[2] is pad_value + assert pad_value.args == ([-11], [1], torch.int8) graph.lint() @pytest.mark.parametrize("final_blend", [False, True]) -def test_preserves_order_when_pads_share_a_shape_constant(final_blend): +def test_preserves_order_when_pads_share_shape_and_value_constants(final_blend): program, inputs, concat, conv = _program() graph = program.graph with graph.inserting_before(graph.output_node()): shape = graph.call_function( TOSA.CONST_SHAPE.default, ([0, 0, 0, 0, 0, 0, 0, 1],) ) + pad_value = pad.create_const(graph, -11, torch.int8, inputs[0]) images = [] for source in inputs[:2]: - image = graph.call_function( - TOSA.PAD.default, (source, shape), {"value": -11} - ) + image = graph.call_function(TOSA.PAD.default, (source, shape, pad_value)) image.meta["val"] = source.meta["val"].new_empty((1, 8, 12, 4)) images.append(image) custom = graph.call_function( @@ -295,5 +313,8 @@ def test_preserves_order_when_pads_share_a_shape_constant(final_blend): graph.output_node().args = (tuple(outputs),) assert pad.PadRifeBlock0ConcatPass(program)(program.graph_module).modified assert [branch.args[0].args[0] for branch in concat.args[0][:2]] == images + assert images[0].args[2].args == ([40], [1], torch.int8) + assert images[1].args[2].args == ([0], [1], torch.int8) + assert images[0].args[2] is not images[1].args[2] assert sum(n.target == TOSA.PAD.default for n in graph.nodes) == 2 graph.lint() diff --git a/examples/arm/QAT_example/rife_vgf/passes/test_rife_blend.py b/examples/arm/QAT_example/rife_vgf/passes/test_rife_blend.py index e369a6c0fb1..bccbd19775c 100644 --- a/examples/arm/QAT_example/rife_vgf/passes/test_rife_blend.py +++ b/examples/arm/QAT_example/rife_vgf/passes/test_rife_blend.py @@ -65,6 +65,7 @@ def rescale(source, dtype, scale, zp, out_zp=0): InputKind.CONSTANT_TENSOR, torch.tensor([[[[127]]]], dtype=torch.int8), ) + shift = node(TOSA.CONST.default, ([0], [1], torch.int8), (1,), torch.int8) scalar = rescale(one, torch.int32, 524288.0, -127) up_mask = rescale(sigmoid, torch.int32, 523948.54785029794, -127) subtract = node(TOSA.SUB.default, (scalar, up_mask), mask_shape) @@ -92,7 +93,7 @@ def rescale(source, dtype, scale, zp, out_zp=0): image = rescale(crop, torch.int32, 1.0, -14) centered_mask = rescale(mask, torch.int32, 1.0, -127) centered_masks.append(centered_mask) - multiply = node(TOSA.MUL.default, (image, centered_mask), image_shape) + multiply = node(TOSA.MUL.default, (image, centered_mask, shift), image_shape) weighted = rescale(multiply, torch.int8, multiply_scale, 0, -127) sides.append(rescale(weighted, torch.int32, add_scale, -127)) add = node(TOSA.ADD.default, tuple(sides), image_shape) @@ -157,9 +158,8 @@ def test_keeps_unsupported_blend_in_tosa(unsupported): TOSA.RESCALE.default, (sigmoid, torch.int32, [1.0], -127, 0) ) elif unsupported == "shifted_mul": - next(n for n in program.graph.nodes if n.target == TOSA.MUL.default).kwargs = { - "shift": 1 - } + multiply = next(n for n in program.graph.nodes if n.target == TOSA.MUL.default) + multiply.args[2].args = ([1], [1], torch.int8) elif unsupported == "scaled_mask": masks[0].args = (*masks[0].args[:2], [2.0], *masks[0].args[3:]) elif unsupported == "unsigned": From a3a1a8e0030d3dc1d3543479389cd7d15733a0d8 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Thu, 5 Nov 2026 14:15:23 -0800 Subject: [PATCH 107/128] MLX: make the harness signal tests independent of the caller, and give the CPU index ranges for 8-byte dtypes (#24646) ## What was wrong - The `run_all_tests --isolate` crashed-test case checked the harness's SIGTERM and SIGQUIT handlers without setting those signals to their defaults first. So it failed when the tests started with SIGQUIT ignored or with their own SIGTERM handler. - The stand-in harness in the signal tests inherited `PYTHONUNBUFFERED`. With it set, printed lines reached the pipe without a flush, so removing the flush still passed. - The op support page said 8-byte `index_put` and `scatter_add` raise on a bad index on the CPU. But `index_add_` and `index_copy` lower to `index_put`, which counts a negative index from the end, while eager PyTorch raises. ## What this change does - The crashed-test case sets SIGTERM and SIGQUIT to their defaults and SIGHUP to ignored, then restores them. - The stand-in harness runs without `PYTHONUNBUFFERED`. - The page gives the CPU ranges: `[-size, size - 1]` for `index_put` and the ops that lower to it, `[0, size - 1]` for `scatter_add`. ## What was tested On an Apple Silicon Mac: with SIGQUIT ignored, the old crashed-test case fails and the new one passes. With `PYTHONUNBUFFERED=1`, removing the flush passes the old signal tests and fails the new ones. --- backends/mlx/test/test_run_all_tests.py | 10 ++++++++-- docs/source/backends/mlx/mlx-op-support.md | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/backends/mlx/test/test_run_all_tests.py b/backends/mlx/test/test_run_all_tests.py index 331c7af1dae..656915b91c4 100644 --- a/backends/mlx/test/test_run_all_tests.py +++ b/backends/mlx/test/test_run_all_tests.py @@ -69,8 +69,12 @@ def wait(self, timeout=None): def test_isolated_run_kills_grandchildren_of_a_crashed_child(self): # An ignored SIGHUP, as under nohup, must still be ignored afterwards. - self.addCleanup(signal.signal, signal.SIGHUP, signal.getsignal(signal.SIGHUP)) - signal.signal(signal.SIGHUP, signal.SIG_IGN) + # The others start at the default, whatever this test run inherited. + for sig, handler in zip( + _HANDLED_SIGNALS, (signal.SIG_DFL, signal.SIG_IGN, signal.SIG_DFL) + ): + self.addCleanup(signal.signal, sig, signal.getsignal(sig)) + signal.signal(sig, handler) handlers = [signal.getsignal(s) for s in _HANDLED_SIGNALS] during = [] @@ -195,6 +199,8 @@ def _start_harness( ], stdout=subprocess.PIPE, text=True, + # Buffered, so a printed line only reaches the pipe if the harness flushes it. + env={k: v for k, v in os.environ.items() if k != "PYTHONUNBUFFERED"}, ) self.addCleanup(harness.stdout.close) self.addCleanup(harness.kill) diff --git a/docs/source/backends/mlx/mlx-op-support.md b/docs/source/backends/mlx/mlx-op-support.md index 7ffadd99364..f10c873eba8 100644 --- a/docs/source/backends/mlx/mlx-op-support.md +++ b/docs/source/backends/mlx/mlx-op-support.md @@ -37,7 +37,7 @@ Other ops that take indices behave differently, for example: - Reading `x[i]` with a Python int raises an error on an index outside `[-size, size - 1]`. - `embedding`, `index_select`, `scatter_add`, and reading `x[idx]` with an index tensor do not check or clamp. An index outside `[-size, size - 1]` there can read or write memory outside the tensor. -When the tensor that `index_put` or `scatter_add` writes has an 8-byte dtype, such as `int64` or `float64`, the MLX backend does not run it. It runs on the CPU instead, which raises an error on a bad index. This also applies to `index_add_`, and to `index_copy` and `index_copy_` where they lower to `index_put`. +When the tensor that `index_put` or `scatter_add` writes has an 8-byte dtype, such as `int64` or `float64`, the MLX backend does not run it. It runs on the CPU instead. There `index_put` on a non-empty tensor raises an error on an index outside `[-size, size - 1]`, and `scatter_add` on an index outside `[0, size - 1]`. This also applies to `index_add_`, and to `index_copy` and `index_copy_` where they lower to `index_put`, so a negative index there still counts from the end. To find bad indices, run the model in eager PyTorch on the CPU first, where they raise. Keep run-time indices in range too, for example by stopping generation before a cache position reaches the cache size. From 4bce1e15c7d1f0a01cceacf3fe6c08908f87441e Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Thu, 5 Nov 2026 14:15:43 -0800 Subject: [PATCH 108/128] Plan dynamic-shape buffers by the exact largest size when it is cheap (#24610) ## What is wrong today `eval_upper_bound` bounds dynamic sizes with interval arithmetic, which can overshoot. For `swin_v2_t` exported with sides `16 * Dim(min=1, max=16)`, without a delegate, the plan is 45.7 GB. Shifted-window attention pads to a multiple of the window, so after export the window size is `padded // (padded // 8)`. That is always 8, but interval arithmetic gets 71, so attention scores are planned at 5041 x 5041, not 64 x 64. A `memory.view` can also be planned larger than its buffer, so the program fails to load. A size with no interval bound is planned by its trace value, which can be too small for the largest inputs. ## What this change does - `ConstraintBasedSymShapeEvalPass` gives `eval_upper_bound` a budget. With it, a size that uses `//` or `%` and has at most 1024 combinations of symbol values is evaluated at each one. The smaller of that maximum and the interval bound wins. Each symbol is also tried, as a best effort, up to 2 below its `ShapeEnv` minimum. The `ShapeEnv` raises a declared 0 or 1 to 2. - The budget is 65,536 evaluated combinations per pass run, set with `ConstraintBasedSymShapeEvalPass(enumeration_budget=...)`, where 0 keeps the interval bounds. Each distinct size is charged once, cached or not, so plans do not depend on earlier exports. - Strides and other callers keep the old bound. - A size with no interval bound is enumerated the same way, outside the budget, before falling back to its trace value. - Memory planning reserves room in each tensor for every `memory.view` of it. For `swin_v2_t` the plan drops to 17 MB. Sizes with more than 1024 combinations, like a plain `Dim(min=32, max=256)` height and width, keep the old bound. ## Cost On x86 Linux, without a delegate, side by side with main: - `swin_v2_t` evaluates about 5,100 combinations, about 2,000 from the budget. `eval_upper_bound` takes 7.4 s instead of 6.7 s. - 45 window shapes, each with 1024 combinations of 2 symbols, evaluate about 51,000, about 19,000 from the budget: 4.7 s instead of 2.3 s, and the plan drops from 1.9 GB to 3.6 MB. - Spending the whole budget takes a few seconds. ## Testing - New tests in `exir/tests/test_memory_planning.py` cover each change and run exported programs at both ends of their range. - The related exir tests pass on Linux, except ones that also fail on main. --- exir/memory_planning.py | 21 + .../replace_view_copy_with_view_pass.py | 1 + exir/passes/sym_shape_eval_pass.py | 27 +- exir/sym_util.py | 172 +++++++- exir/tensor.py | 7 +- exir/tests/targets.bzl | 1 + exir/tests/test_memory_planning.py | 368 +++++++++++++++++- 7 files changed, 590 insertions(+), 7 deletions(-) diff --git a/exir/memory_planning.py b/exir/memory_planning.py index ee04ab9ddac..04ec4f4cc31 100644 --- a/exir/memory_planning.py +++ b/exir/memory_planning.py @@ -1431,6 +1431,26 @@ def _build_non_const_buffer_device( ] +def _reserve_memory_for_views(graph_module: torch.fx.GraphModule) -> None: + """ + A memory.view shares its base's memory, but each of its dims is bounded on its + own, so the view can be planned larger than its base. The runtime refuses to + load a tensor whose planned size runs past the end of its buffer, so plan each + base large enough for its views. + """ + for node in graph_module.graph.nodes: + if node.op != "call_function" or node.target != memory.view: + continue + spec = node.meta["spec"] + # A view's spec writes this field through to its base. A base backed by + # another tensor's storage must still fit in that storage. + needed = spec.nbytes() + while spec is not None: + spec.min_allocated_memory = max(spec.min_allocated_memory, needed) + needed += spec.storage_base_offset + spec = spec.storage_base + + def apply_algo( algo: Callable[..., list[int]], graph_module: torch.fx.GraphModule, @@ -1467,6 +1487,7 @@ def apply_algo( """ # Extract the nodes and their lifespans from the graph_module _ = update_all_tensors_lifetime(graph_module, graph_signature) + _reserve_memory_for_views(graph_module) # Collect specs into an ordered list so we can iterate multiple times and # partition by device. Order matters: order-sensitive algorithms (e.g. diff --git a/exir/passes/replace_view_copy_with_view_pass.py b/exir/passes/replace_view_copy_with_view_pass.py index 20dd4ca22c3..ddfe9f346c1 100644 --- a/exir/passes/replace_view_copy_with_view_pass.py +++ b/exir/passes/replace_view_copy_with_view_pass.py @@ -274,6 +274,7 @@ def __init__(self, base: TensorSpec, shape: List[int]) -> None: "mem_offset", "storage_base", "storage_base_offset", + "min_allocated_memory", "dtype", # property "extra_tensor_info", # property "device", diff --git a/exir/passes/sym_shape_eval_pass.py b/exir/passes/sym_shape_eval_pass.py index bfc0165f2c0..78a98905983 100644 --- a/exir/passes/sym_shape_eval_pass.py +++ b/exir/passes/sym_shape_eval_pass.py @@ -14,7 +14,12 @@ from executorch.exir._warnings import deprecated from executorch.exir.dialects._ops import ops as exir_ops from executorch.exir.pass_base import PassBase, PassResult -from executorch.exir.sym_util import eval_expr, eval_shape, eval_upper_bound +from executorch.exir.sym_util import ( + EnumerationBudget, + eval_expr, + eval_shape, + eval_upper_bound, +) from executorch.exir.tensor import TensorSpec from torch.fx import GraphModule @@ -262,9 +267,22 @@ class ConstraintBasedSymShapeEvalPass(PassBase): Not inherited from ExportPass since we simply need a way to iterate through every node's output. PassBase is easier for that purpose. + + Args: + enumeration_budget: How many symbol value combinations one run may evaluate + to tighten the upper bounds of sizes that use // or %. 0 keeps the + interval bounds. A size with no interval bound is enumerated without + using the budget. """ + # The budget caps how much time tightening finite bounds can add to an export. + # Spending all of it takes a few seconds, while swin_v2_t uses about 2,000. + def __init__(self, enumeration_budget: int = 64 * 1024) -> None: + super().__init__() + self.enumeration_budget = enumeration_budget + def call(self, graph_module: GraphModule): + budget = EnumerationBudget(self.enumeration_budget) for subgm in graph_module.modules(): if not isinstance(subgm, GraphModule): continue @@ -272,7 +290,12 @@ def call(self, graph_module: GraphModule): for spec in pytree.tree_flatten(node.meta.get("spec", []))[0]: # Node for function like aten.sym_size does not have spec if isinstance(spec, TensorSpec): - concrete_shape = [eval_upper_bound(s) for s in spec.shape] + concrete_shape = [ + eval_upper_bound(s, budget=budget) for s in spec.shape + ] + # Only shapes feed memory planning and serialization, and + # strides would take most of the enumeration time, so strides + # get no budget and are never enumerated. concrete_stride = [eval_upper_bound(s) for s in spec.stride] if any(not isinstance(s, int) for s in concrete_shape) or any( not isinstance(s, int) for s in concrete_stride diff --git a/exir/sym_util.py b/exir/sym_util.py index e639b84ee0a..07518b37a9d 100644 --- a/exir/sym_util.py +++ b/exir/sym_util.py @@ -6,7 +6,9 @@ # pyre-strict -from typing import Iterable, List, Optional, Set, Union +import functools +import itertools +from typing import Dict, Iterable, List, Optional, Set, Tuple, Union import sympy @@ -35,10 +37,150 @@ def eval_expr(symint: Union[int, torch.SymInt]) -> Optional[int]: return int(output) -def eval_upper_bound(maybe_symint: Union[int, torch.SymInt]) -> int: +# The most symbol value combinations evaluated for one size. +_MAX_ENUMERATED_POINTS = 1024 + + +# A size and the ranges and ceiling it is enumerated over. +_EnumerationKey = Tuple[sympy.Expr, Tuple[Tuple[sympy.Symbol, int, int], ...], int] + + +class EnumerationBudget: + """ + Limits how many symbol value combinations eval_upper_bound evaluates to tighten + the finite bounds of the sizes it is given for. A size is tried only when all its + combinations fit in what is left, and is then charged the combinations it + evaluated. Each distinct size is charged once, whether or not its result is + already cached, so the bounds do not depend on what earlier programs evaluated. A + budget of 0 tries no such size. Sizes with no interval bound are not charged. + """ + + def __init__(self, points: int) -> None: + self.remaining = points + self._charged: Set[_EnumerationKey] = set() + + def admits(self, key: _EnumerationKey, points: int) -> bool: + return key in self._charged or points <= self.remaining + + def charge(self, key: _EnumerationKey, evaluated: int) -> None: + if key not in self._charged: + self.remaining -= evaluated + self._charged.add(key) + + +@functools.lru_cache(maxsize=4096) +def _max_over_points( + expr: sympy.Expr, + ranges: Tuple[Tuple[sympy.Symbol, int, int], ...], + ceiling: Optional[int], +) -> Tuple[Optional[int], int]: + """ + Return the largest value of expr over ranges, or None when it cannot tell, and + how many combinations were evaluated. + """ + # Programs reuse the same few size expressions across many tensors, so the + # evaluation is cached. + symbols = [symbol for symbol, _, _ in ranges] + largest = None + evaluated = 0 + # Sizes mostly grow with their symbols, so trying large values first usually + # reaches the ceiling, past which the caller's min() cannot change, early. + for values in itertools.product( + *(range(upper, lower - 1, -1) for _, lower, upper in ranges) + ): + evaluated += 1 + try: + result = expr.xreplace(dict(zip(symbols, map(sympy.Integer, values)))) + except ZeroDivisionError: + # A conditional size also evaluates the branch it does not take, so a + # division by zero there says nothing about the program. + if expr.has(sympy.Piecewise): + return None, evaluated + # Otherwise the size divides by zero here, so the program cannot run + # with these values and needs no memory for them. + continue + except Exception: + return None, evaluated + if not isinstance(result, sympy.Integer): + return None, evaluated + largest = int(result) if largest is None else max(largest, int(result)) + if ceiling is not None and largest >= ceiling: + break + return largest, evaluated + + +def _enumeration_ranges( + expr: sympy.Expr, + var_to_range: Dict[sympy.Symbol, ValueRanges], # pyre-ignore[24] + below_minimum: int = 2, +) -> Optional[Tuple[Tuple[Tuple[sympy.Symbol, int, int], ...], int]]: + """ + Return the integer range to try for each symbol of expr, starting up to + below_minimum values under its ShapeEnv minimum, and the number of + combinations. Returns None when expr has no function such as // or %, since + interval arithmetic is usually close for those and enumerating would only cost + time, when a symbol has no finite range, or when there are more than + _MAX_ENUMERATED_POINTS combinations. + """ + if not expr.atoms(sympy.Function): + return None + ranges = [] + points = 1 + for symbol in sorted(expr.free_symbols, key=str): + value_range = var_to_range.get(symbol) + if value_range is None or not ( + isinstance(value_range.lower, sympy.Integer) + and isinstance(value_range.upper, sympy.Integer) + ): + return None + # The ShapeEnv raises a declared minimum of 0 or 1 to 2, and raises the root + # of a derived dim such as dim - 1 until that dim is at least 2, so the + # program can run up to 2 below the recorded minimum, so by default those + # values are tried too. Like the interval bound, this is a best effort there, + # since the ShapeEnv simplified expr for its own range. + lower, upper = int(value_range.lower), int(value_range.upper) + lower = min(lower, max(lower - below_minimum, 0)) + points *= upper - lower + 1 + if points > _MAX_ENUMERATED_POINTS: + return None + ranges.append((symbol, lower, upper)) + return tuple(ranges), points + + +def _enumerated_upper_bound( + expr: sympy.Expr, + var_to_range: Dict[sympy.Symbol, ValueRanges], # pyre-ignore[24] + ceiling: int, + budget: EnumerationBudget, +) -> Optional[int]: + """ + Evaluate expr at every combination of its symbols' integer values and return the + largest result, or a result of at least ceiling once one is found. Returns None + when _enumeration_ranges does, when there are more combinations than budget has + left, or when no combination can be evaluated. A division by zero skips that + combination, unless expr is conditional, and any other failure returns None. + """ + enumeration = _enumeration_ranges(expr, var_to_range) + if enumeration is None: + return None + ranges, points = enumeration + key = (expr, ranges, ceiling) + if not budget.admits(key, points): + return None + largest, evaluated = _max_over_points(*key) + budget.charge(key, evaluated) + return largest + + +def eval_upper_bound( + maybe_symint: Union[int, torch.SymInt], + budget: Optional[EnumerationBudget] = None, +) -> int: """ Evaluate a symint to its uppper bound value. Returns None if symint's symoblic expr's upper bound can not be evaluated to valid integer according to the constraints in shape_env. + With a budget, a finite bound is tightened by enumeration as far as the budget + allows, and a size with no interval bound is enumerated outside the budget. """ if isinstance(maybe_symint, int): return maybe_symint @@ -58,11 +200,37 @@ def eval_upper_bound(maybe_symint: Union[int, torch.SymInt]) -> int: if isinstance(upper_bound, sympy.Integer): concrete_upper = int(var_range.upper) + # Interval arithmetic can overshoot by far: for x // (x // 8) with x in [8, 71] + # it gives 71, while the largest value is 15. Keeping the smaller of the two + # means the bound can only get tighter. + enumerated_upper = ( + None + if budget is None + else _enumerated_upper_bound( + expr, shape_env.var_to_range, concrete_upper, budget + ) + ) + if enumerated_upper is not None: + concrete_upper = min(concrete_upper, enumerated_upper) assert isinstance( concrete_upper, int ), f"Expect upper bound to be a concrete int but got {concrete_upper}" return concrete_upper elif int_oo is not None and upper_bound is int_oo: + # Interval arithmetic gives no bound when a size divides by a range that + # contains 0, even when every symbol is bounded, and the trace hint below + # is then often too small. So unlike tightening a finite bound, this is + # not left to what the budget has left, and when the values below the + # minimum push the size over the cap or fail, it is tried with fewer of them. + if budget is not None: + for below_minimum in (2, 1, 0): + enumeration = _enumeration_ranges( + expr, shape_env.var_to_range, below_minimum + ) + if enumeration is not None: + largest, _ = _max_over_points(expr, enumeration[0], None) + if largest is not None: + return largest hint = eval_expr(maybe_symint) if hint is not None: return hint diff --git a/exir/tensor.py b/exir/tensor.py index 40d6dfbb060..541f862fa9f 100644 --- a/exir/tensor.py +++ b/exir/tensor.py @@ -215,7 +215,10 @@ def __init__( @property def allocated_memory(self) -> int: - nbytes = num_bytes_from_shape_and_dtype(self.shape, self.dtype) + nbytes = max( + num_bytes_from_shape_and_dtype(self.shape, self.dtype), + self.min_allocated_memory, + ) return calculate_aligned_num_bytes(nbytes, self.alignment) def realign(self, new_alignment: int) -> int: @@ -259,6 +262,8 @@ def init_mem_planning_fields(self) -> None: self.storage_base: Optional["TensorSpec"] = None # Byte offset into storage_base when this spec is storage-backed. self.storage_base_offset: int = 0 + # Bytes to plan at least, so that a memory.view of this tensor fits too. + self.min_allocated_memory: int = 0 @property def inplace_base(self) -> Optional["TensorSpec"]: diff --git a/exir/tests/targets.bzl b/exir/tests/targets.bzl index 2ad0a4ebb17..a19fd659a39 100644 --- a/exir/tests/targets.bzl +++ b/exir/tests/targets.bzl @@ -210,6 +210,7 @@ def define_common_targets(is_fbcode = False): "//executorch/exir/passes:sym_shape_eval_pass", "//executorch/examples/models/llama:custom_kv_cache", "//executorch/extension/llm/custom_ops:custom_ops_aot_py", + "//executorch/extension/pybindings:portable_lib", # @manual ], ) diff --git a/exir/tests/test_memory_planning.py b/exir/tests/test_memory_planning.py index ea9a2362da5..67e6cce5f5e 100644 --- a/exir/tests/test_memory_planning.py +++ b/exir/tests/test_memory_planning.py @@ -9,7 +9,7 @@ import itertools import unittest -from typing import Any, Callable, cast, List, Optional, Tuple, Type +from typing import Any, Callable, cast, Dict, List, Optional, Tuple, Type import executorch.exir as exir @@ -25,7 +25,13 @@ del logging import torch -from executorch.exir import EdgeCompileConfig, ExecutorchBackendConfig, to_edge +from executorch.exir import ( + EdgeCompileConfig, + ExecutorchBackendConfig, + ExecutorchProgramManager, + memory, + to_edge, +) from executorch.exir.capture._capture import patch_forward from executorch.exir.dialects._ops import ops as exir_ops from executorch.exir.error import InternalError @@ -33,6 +39,7 @@ from executorch.exir.memory_planning import ( _do_user_inputs_exist, _extend_storage_base_lifetimes, + _reserve_memory_for_views, apply_algo, collect_specs_from_nodes, filter_nodes, @@ -51,12 +58,18 @@ ToOutVarPass, ) from executorch.exir.passes.reinplace import DEFAULT_INPLACEABLE_OPS, reinplace_pass +from executorch.exir.passes.replace_view_copy_with_view_pass import _ViewSpec from executorch.exir.passes.sym_shape_eval_pass import ConstraintBasedSymShapeEvalPass from executorch.exir.schema import DeviceType +from executorch.exir.sym_util import EnumerationBudget, eval_upper_bound from executorch.exir.tensor import TensorSpec +from executorch.extension.pybindings.portable_lib import ( # @manual=//executorch/extension/pybindings:portable_lib + _load_for_executorch_from_buffer, +) from functorch.experimental.control_flow import map as torch_map from parameterized import parameterized from torch import nn +from torch._dynamo.source import ConstantSource from torch.ao.quantization import ( # @manual=//caffe2:torch float_qparams_weight_only_qconfig, ) @@ -76,8 +89,11 @@ from torch.export.experimental import _export_forward_backward from torch.export.exported_program import ExportGraphSignature from torch.fx import Graph, GraphModule, Node +from torch.fx.experimental.symbolic_shapes import DimDynamic, ShapeEnv from torch.nn import functional as F from torch.utils import _pytree as pytree +from torch.utils._sympy.numbers import int_oo +from torch.utils._sympy.value_ranges import bound_sympy def swap_modules( @@ -2115,3 +2131,351 @@ def forward(self, a: torch.Tensor, b: torch.Tensor) -> torch.Tensor: if node.op == "call_function" ) self.assertFalse(has_inplace) + + +def _ranged_symint( + shape_env: ShapeEnv, name: str, hint: int, low: int, high: int +) -> torch.SymInt: + symbol = shape_env.create_symbol( + hint, ConstantSource(name), dynamic_dim=DimDynamic.DYNAMIC + ) + shape_env.constrain_symbol_range(symbol, compiler_min=low, compiler_max=high) + symint = shape_env.create_symintnode(symbol, hint=hint) + assert isinstance(symint, torch.SymInt) + return symint + + +def _window(s: torch.SymInt) -> torch.SymInt: + # Padding to a multiple of 8 and splitting into windows, as shifted-window + # attention does. The result is always 8. + padded = 4 * s + (8 - (4 * s) % 8) % 8 + return padded // (padded // 8) + + +class TestDynamicShapeUpperBound(unittest.TestCase): + def test_enumerates_small_ranges(self) -> None: + """Interval arithmetic bounds the window size far above 8, so memory + planning sized window attention buffers thousands of times too big.""" + shape_env = ShapeEnv() + h = _ranged_symint(shape_env, "h", 4, 2, 16) + w = _ranged_symint(shape_env, "w", 5, 2, 16) + + for size, exact in ((_window(h), 8), (_window(h) * _window(w), 64)): + self.assertGreater( + bound_sympy(size.node.expr, shape_env.var_to_range).upper, exact + ) + self.assertEqual(eval_upper_bound(size, EnumerationBudget(1024)), exact) + + def test_enumerates_ranges_that_fit_under_the_cap(self) -> None: + """Two symbols in [8, 32] have 625 combinations, so trying values far + below their minimums would push them past the cap of 1024.""" + shape_env = ShapeEnv() + h = _ranged_symint(shape_env, "h", 8, 8, 32) + w = _ranged_symint(shape_env, "w", 9, 8, 32) + + size = _window(h) * _window(w) + self.assertGreater( + bound_sympy(size.node.expr, shape_env.var_to_range).upper, 64 + ) + self.assertEqual(eval_upper_bound(size, EnumerationBudget(1024)), 64) + + def test_covers_values_below_the_shape_env_minimum(self) -> None: + """The ShapeEnv holds a declared minimum of 0 or 1 as 2, but the program + still runs at 0 and 1.""" + shape_env = ShapeEnv() + s = _ranged_symint(shape_env, "s", 4, 2, 16) + + for size, largest in ( + # Largest at 1 (32), above every value in [2, 16] (at most 21). + (((s + 7) // 8) * (64 // (s + 1)), 32), + # Largest at 0, and within the interval bound of 70. + (64 // (s + 1) + 7 * (s % 8), 64), + ): + self.assertEqual(eval_upper_bound(size, EnumerationBudget(1024)), largest) + + def test_enumerates_when_interval_bound_is_unbounded(self) -> None: + """Interval arithmetic gives no bound for a window over a range that starts + below the window size, so the size was planned by its trace value.""" + shape_env = ShapeEnv() + h = _ranged_symint(shape_env, "h", 4, 2, 16) + w = _ranged_symint(shape_env, "w", 5, 2, 16) + padded = w + (7 - w % 7) % 7 + # The window size is always 7, and h % 9 is largest at h = 8. + size = (h % 9) * (padded // (padded // 7)) + + self.assertIs(bound_sympy(size.node.expr, shape_env.var_to_range).upper, int_oo) + self.assertEqual(eval_upper_bound(size), 4 * 7) + # The trace value is too small, so this does not depend on the budget. + self.assertEqual(eval_upper_bound(size, EnumerationBudget(0)), 8 * 7) + # Largest at h = 0, below the ShapeEnv minimum. + size = (64 // (h + 1)) * (padded // (padded // 7)) + self.assertEqual(eval_upper_bound(size, EnumerationBudget(0)), 64 * 7) + # Not an integer at h = 0 and 1, so it is tried again from the minimum. + size = 2 ** ((h - 2) // 4) * (padded // (padded // 7)) + self.assertEqual(eval_upper_bound(size, EnumerationBudget(0)), 8 * 7) + # Values 2 below the minimum would make 33 * 33 combinations, over the cap, + # but 1 below still fits and reaches the largest value, at a = 1. + a = _ranged_symint(shape_env, "a", 4, 2, 32) + b = _ranged_symint(shape_env, "b", 5, 2, 32) + padded = b + (7 - b % 7) % 7 + size = (64 // a) * (padded // (padded // 7)) + self.assertEqual(eval_upper_bound(size, EnumerationBudget(0)), 64 * 7) + + def test_memory_planning_bounds_size_with_no_interval_bound(self) -> None: + class WindowsTimesWidth(torch.nn.Module): + def forward(self, x: torch.Tensor, y: torch.Tensor) -> torch.Tensor: + h = x.shape[0] + padded = h + (7 - h % 7) % 7 + return x.new_ones(padded // (padded // 7) * y.shape[0]) + + model = WindowsTimesWidth() + program = to_edge( + export( + model, + (torch.randn(12, 4), torch.randn(3, 4)), + dynamic_shapes=( + {0: torch.export.Dim("h", min=1, max=32)}, + {0: torch.export.Dim("w", min=1, max=32)}, + ), + ) + ).to_executorch() + + # The trace value is 7 * 3, but the largest inputs need 7 * 32. Starting 2 + # below the minimum would make 33 * 33 combinations, over the cap. + runtime = _load_for_executorch_from_buffer(program.buffer) + for x, y in ( + (torch.randn(12, 4), torch.randn(3, 4)), + (torch.randn(32, 4), torch.randn(32, 4)), + ): + torch.testing.assert_close(runtime.forward([x, y])[0], model(x, y)) + + def test_keeps_interval_bound(self) -> None: + shape_env = ShapeEnv() + large = _ranged_symint(shape_env, "large", 4, 2, 2000) + s = _ranged_symint(shape_env, "s", 4, 2, 16) + unbacked = shape_env.create_unbacked_symint() + torch._check(unbacked >= 0) + torch._check(unbacked <= 16) + unbounded = shape_env.create_unbacked_symint() + torch._check(unbounded >= 0) + + for size in ( + # More values than one size may try, though within the budget. + _window(large), + # A float result at s = 0, which sympy gives for TruncToInt(0). + torch.sym_int(s / 3) + _window(s + 1), + # No // or %. Interval arithmetic overshoots here too, but it is close + # for most such sizes, so they are not enumerated. + s * (16 - s), + # No upper bound to try up to. Interval arithmetic gives 49, while the + # largest value is 12. + (unbounded % 8) * (7 - unbounded % 8), + # A negative power at s = 0 and s = 1. + _window(s) + 2 ** ((s - 2) // 4), + # A division by zero in the branch not taken at unbacked = 0. + torch.sym_min(torch.sym_ite(unbacked > 0, s // unbacked, 2 * s), 64), + # Largest at 1 (40), above the interval bound (30), which wins. + (s % 2 + 1) * (60 // (s + 2)), + ): + self.assertEqual( + eval_upper_bound(size, EnumerationBudget(64 * 1024)), + int(bound_sympy(size.node.expr, shape_env.var_to_range).upper), + ) + + def test_budget_limits_combinations_across_sizes(self) -> None: + shape_env = ShapeEnv() + h = _ranged_symint(shape_env, "h", 4, 2, 16) + w = _ranged_symint(shape_env, "w", 5, 2, 16) + t = _ranged_symint(shape_env, "t", 4, 2, 10) + budget = EnumerationBudget(300) + + # 17 x 17 = 289 combinations fit, and the same size again costs nothing, + # which leaves exactly enough for the 11 combinations of the next size. + for _ in range(2): + self.assertEqual(eval_upper_bound(_window(h) * _window(w), budget), 64) + self.assertEqual(eval_upper_bound(_window(t), budget), 8) + # 17 more do not fit, so this size keeps the interval bound. + size = _window(h) + self.assertEqual( + eval_upper_bound(size, budget), + int(bound_sympy(size.node.expr, shape_env.var_to_range).upper), + ) + + def test_budget_charges_combinations_evaluated(self) -> None: + shape_env = ShapeEnv() + large = _ranged_symint(shape_env, "large", 4, 2, 1000) + h = _ranged_symint(shape_env, "h", 4, 2, 16) + budget = EnumerationBudget(1001) + + # 1001 combinations fit, but the first one reaches the interval bound, so + # only it is charged and the next size still fits. + self.assertEqual(eval_upper_bound(large // 2, budget), 500) + self.assertEqual(eval_upper_bound(_window(h), budget), 8) + + def test_one_budget_covers_the_whole_pass(self) -> None: + shape_env = ShapeEnv() + h = _ranged_symint(shape_env, "h", 4, 2, 16) + w = _ranged_symint(shape_env, "w", 5, 2, 16) + sym_shape_eval_pass = ConstraintBasedSymShapeEvalPass(enumeration_budget=17) + + # 17 combinations fit the first size only, even though the second one is + # in a submodule. The second run starts with the other size, which fits + # only because every run gets a new budget. + for first, second in ((h, w), (w, h)): + graph_modules = [] + specs = [] + for symbol in (first, second): + graph = torch.fx.Graph() + spec = TensorSpec(torch.float32, torch.Size([_window(symbol)])) + graph.placeholder("x").meta["spec"] = spec + graph.output(None) + graph_modules.append(torch.fx.GraphModule(torch.nn.Module(), graph)) + specs.append(spec) + graph_modules[0].add_module("submodule", graph_modules[1]) + sym_shape_eval_pass(graph_modules[0]) + interval = bound_sympy(_window(second).node.expr, shape_env.var_to_range) + self.assertEqual(specs[0].shape, [8]) + self.assertEqual(specs[1].shape, [int(interval.upper)]) + + def test_memory_planning_uses_exact_window_size(self) -> None: + class Windows(torch.nn.Module): + def forward(self, x: torch.Tensor) -> torch.Tensor: + h = x.shape[1] + x = torch.nn.functional.pad(x, (0, 0, 0, (8 - h % 8) % 8)) + return x.view(x.shape[0], x.shape[1] // 8, -1, x.shape[2]) * 2 + + model = Windows() + h = torch.export.Dim("h", min=1, max=16) * 4 + + def plan(enumeration_budget: int) -> Tuple[ExecutorchProgramManager, int, int]: + program = to_edge( + export(model, (torch.randn(1, 20, 4),), dynamic_shapes=({1: h},)) + ).to_executorch( + ExecutorchBackendConfig( + sym_shape_eval_pass=ConstraintBasedSymShapeEvalPass( + enumeration_budget=enumeration_budget + ) + ) + ) + graph = program.exported_program().graph_module.graph + result = graph.output_node().args[0][0] + size = result.meta["val"].shape[2] + interval = bound_sympy(size.node.expr, size.node.shape_env.var_to_range) + return program, result.meta["spec"].shape[2], int(interval.upper) + + program, planned, interval = plan(64 * 1024) + self.assertGreater(interval, 8) + self.assertEqual(planned, 8) + # 4 is below the minimum the ShapeEnv keeps for the symbol. + runtime = _load_for_executorch_from_buffer(program.buffer) + for size in (4, 64): + x = torch.randn(1, size, 4) + torch.testing.assert_close(runtime.forward([x])[0], model(x)) + + # A budget of 0 keeps the interval bound. + _, planned, interval = plan(0) + self.assertEqual(planned, interval) + + +class TestViewPlanning(unittest.TestCase): + def _assert_loads( + self, + model: torch.nn.Module, + example: Tuple[torch.Tensor, ...], + dynamic_shapes: Tuple[Dict[int, Any], ...], + config: ExecutorchBackendConfig, + inputs: List[Tuple[torch.Tensor, ...]], + ) -> List[torch.fx.Node]: + program = to_edge( + export(model, example, dynamic_shapes=dynamic_shapes) + ).to_executorch(config) + + views = [ + node + for node in program.exported_program().graph_module.graph.nodes + if node.op == "call_function" and node.target == memory.view + ] + # Each dim of the first view is bounded on its own, so it is planned larger + # than the tensor it points into, which must make room for every view. + base = views[0].args[0].meta["spec"] + self.assertGreater(views[0].meta["spec"].nbytes(), base.nbytes()) + for view in views: + self.assertGreaterEqual(base.allocated_memory, view.meta["spec"].nbytes()) + + runtime = _load_for_executorch_from_buffer(program.buffer) + for args in inputs: + torch.testing.assert_close( + runtime.forward(list(args))[0], model(*args), rtol=1e-4, atol=1e-4 + ) + return views + + def test_view_planned_larger_than_base_loads(self) -> None: + class PadThenSplit(torch.nn.Module): + def forward(self, x: torch.Tensor) -> torch.Tensor: + h = x.shape[1] + x = torch.nn.functional.pad(x, (0, 0, 0, (8 - h % 8) % 8)) + # These run in place, so their results live in the product's memory. + x = torch.softmax(torch.softmax(x * 2, -1), -1) + windows = x.view(x.shape[0], x.shape[1] // 8, -1, x.shape[2]) + return windows.sum(-1) + x.view(-1).sum() + + h = torch.export.Dim("h", min=1, max=16) * 4 + # With interval bounds, the windows view's window dim is bounded by 71. + views = self._assert_loads( + PadThenSplit(), + (torch.randn(1, 20, 4),), + ({1: h},), + ExecutorchBackendConfig( + reinplace_extra_ops=frozenset({exir_ops.edge.aten._softmax.default}), + sym_shape_eval_pass=ConstraintBasedSymShapeEvalPass( + enumeration_budget=0 + ), + ), + [(torch.randn(1, 4, 4),), (torch.randn(1, 64, 4),)], + ) + self.assertEqual(len(views), 2) + self.assertIsNotNone(views[0].args[0].meta["spec"].storage_base) + + def test_view_over_enumeration_cap_loads(self) -> None: + class SplitThenMerge(torch.nn.Module): + def forward(self, x: torch.Tensor, y: torch.Tensor) -> torch.Tensor: + h = x.shape[0] + padded = h + (8 - h % 8) % 8 + windows = x.new_ones(padded // (padded // 8), y.shape[0], 4) + return windows.view(-1, 4).sum(0) + + h = torch.export.Dim("h", min=1, max=32) + w = torch.export.Dim("w", min=1, max=32) + # The base's window dim is enumerated to 8, but the view's merged dim has + # too many combinations and keeps the interval bound. + self._assert_loads( + SplitThenMerge(), + (torch.randn(20, 4), torch.randn(12, 4)), + ({0: 4 * h}, {0: 4 * w}), + ExecutorchBackendConfig(), + [ + (torch.randn(4, 4), torch.randn(4, 4)), + (torch.randn(128, 4), torch.randn(128, 4)), + ], + ) + + def test_reserves_room_up_the_storage_chain(self) -> None: + root = TensorSpec.from_tensor(torch.empty(48, dtype=torch.uint8)) + storage = TensorSpec.from_tensor(torch.empty(32, dtype=torch.uint8)) + storage.storage_base = root + storage.storage_base_offset = 16 + base = TensorSpec.from_tensor(torch.empty(16, dtype=torch.uint8)) + base.storage_base = storage + base.storage_base_offset = 16 + view = _ViewSpec(base, [16]) + view.shape = [64] + + graph = Graph() + node = graph.call_function(memory.view, (graph.placeholder("base"), [64])) + node.meta["spec"] = view + graph.output(()) + _reserve_memory_for_views(GraphModule({}, graph)) + + self.assertEqual(base.allocated_memory, 64) + self.assertEqual(storage.allocated_memory, 16 + 64) + self.assertEqual(root.allocated_memory, 16 + 16 + 64) From 78076ce2f1f6733e54dd43413f6e0483f0c13d06 Mon Sep 17 00:00:00 2001 From: Digant Desai Date: Tue, 3 Nov 2026 06:24:21 -0800 Subject: [PATCH 109/128] [executorch][test-suite] Feed the Linear tests inputs with the model's 67 input features Pull Request resolved: https://github.com/pytorch/executorch/pull/24355 Eight of the fourteen `test_linear.py` models were fed 64-feature inputs, but `Model` defaults to `in_features=67`, so eager rejected them and every backend skipped them. The dtype, no-bias, batch-size and leading-batch tests now pass 67-feature inputs. Authored with Claude Code. @bypass-github-export-checks @bypass-github-pytorch-ci-checks @bypass-github-executorch-ci-checks ghstack-source-id: 463657740 @exported-using-ghexport Differential Revision: [D128254891](https://our.internmc.facebook.com/intern/diff/D128254891/) --- backends/test/suite/operators/test_linear.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/backends/test/suite/operators/test_linear.py b/backends/test/suite/operators/test_linear.py index 30ae963a1ba..0dc4ef3fedb 100644 --- a/backends/test/suite/operators/test_linear.py +++ b/backends/test/suite/operators/test_linear.py @@ -41,7 +41,7 @@ class Linear(OperatorTest): def test_linear_dtype(self, flow: TestFlow, dtype) -> None: self._test_op( Model().to(dtype), - ((torch.rand(16, 64) * 10).to(dtype),), + ((torch.rand(16, 67) * 10).to(dtype),), flow, ) @@ -49,7 +49,7 @@ def test_linear_dtype(self, flow: TestFlow, dtype) -> None: def test_linear_no_bias_dtype(self, flow: TestFlow, dtype) -> None: self._test_op( Model(bias=False).to(dtype), - ((torch.rand(16, 64) * 10).to(dtype),), + ((torch.rand(16, 67) * 10).to(dtype),), flow, ) @@ -78,7 +78,7 @@ def test_linear_feature_sizes(self, flow: TestFlow) -> None: def test_linear_no_bias(self, flow: TestFlow) -> None: self._test_op( Model(bias=False), - (torch.randn(16, 64),), + (torch.randn(16, 67),), flow, ) self._test_op( @@ -90,17 +90,17 @@ def test_linear_no_bias(self, flow: TestFlow) -> None: def test_linear_batch_sizes(self, flow: TestFlow) -> None: self._test_op( Model(), - (torch.randn(8, 64),), + (torch.randn(8, 67),), flow, ) self._test_op( Model(), - (torch.randn(32, 64),), + (torch.randn(32, 67),), flow, ) self._test_op( Model(), - (torch.randn(100, 64),), + (torch.randn(100, 67),), flow, ) @@ -114,11 +114,11 @@ def test_linear_unbatched(self, flow: TestFlow) -> None: def test_linear_leading_batch(self, flow: TestFlow) -> None: self._test_op( Model(), - (torch.randn(4, 8, 64),), + (torch.randn(4, 8, 67),), flow, ) self._test_op( Model(), - (torch.randn(2, 4, 8, 64),), + (torch.randn(2, 4, 8, 67),), flow, ) From f9edf889f8f677ed1f67809a70508bb53f0ba168 Mon Sep 17 00:00:00 2001 From: Digant Desai Date: Tue, 3 Nov 2026 06:24:22 -0800 Subject: [PATCH 110/128] [executorch][native] Split engine host factories out of the PTN runners Pull Request resolved: https://github.com/pytorch/executorch/pull/24356 Drivers other than `ptn_runner_main` need to link an engine without its runner binary. Each engine's `make_engine_host()` moves into a `*_engine_host` library and `PtnRunner.h` into `engine_host`; the runners are unchanged. Authored with Claude Code. Differential Revision: [D128254879](https://our.internmc.facebook.com/intern/diff/D128254879/) **NOTE FOR REVIEWERS**: This PR has internal Meta-specific changes or comments, please review them on [Phabricator](https://our.internmc.facebook.com/intern/diff/D128254879/)! ghstack-source-id: 463657752 --- backends/native/test/runtime/BUCK | 14 ++++++++--- backends/native/test/runtime/PtnRunner.h | 4 ++-- backends/native/test/runtime/ggml/BUCK | 23 +++++++++++++++---- .../test/runtime/ggml/ggml_ptn_runner.cpp | 2 +- .../native/test/runtime/vulkan/targets.bzl | 12 ++++++++-- .../test/runtime/vulkan/vulkan_ptn_runner.cpp | 2 +- 6 files changed, 44 insertions(+), 13 deletions(-) diff --git a/backends/native/test/runtime/BUCK b/backends/native/test/runtime/BUCK index 80136220925..8ce73cd9078 100644 --- a/backends/native/test/runtime/BUCK +++ b/backends/native/test/runtime/BUCK @@ -8,16 +8,24 @@ non_fbcode_target(_kind = define_common_targets) fbcode_target(_kind = define_common_targets) -# Engine-neutral PTN runner; each engine's runner binary adds make_engine_host(). +# Declares make_engine_host(), which each engine's host library defines. +fbcode_target( + _kind = runtime.cxx_library, + name = "engine_host", + exported_headers = ["PtnRunner.h"], + visibility = ["//executorch/backends/native/test/..."], + exported_deps = ["//executorch/backends/native/runtime/engine:engine"], +) + +# Engine-neutral PTN runner; each engine's runner binary adds its host library. fbcode_target( _kind = runtime.cxx_library, name = "ptn_runner_main", srcs = ["ptn_runner_main.cpp"], - exported_headers = ["PtnRunner.h"], link_whole = True, undefined_symbols = True, visibility = ["//executorch/backends/native/test/..."], - exported_deps = ["//executorch/backends/native/runtime/engine:engine"], + exported_deps = [":engine_host"], deps = [ "//executorch/backends/native/runtime:runtime", "//executorch/backends/native/runtime/deserialize:package", diff --git a/backends/native/test/runtime/PtnRunner.h b/backends/native/test/runtime/PtnRunner.h index 830b7fa4f62..de23aeb4da4 100644 --- a/backends/native/test/runtime/PtnRunner.h +++ b/backends/native/test/runtime/PtnRunner.h @@ -12,8 +12,8 @@ namespace ptn::testing { -// Defined once per runner binary; ptn_runner_main.cpp drives the host it -// returns, so every engine runs a PTN through the same harness. +// Defined once per binary by an engine's host library; drivers such as +// ptn_runner_main.cpp run every engine through the host it returns. std::unique_ptr make_engine_host(); } // namespace ptn::testing diff --git a/backends/native/test/runtime/ggml/BUCK b/backends/native/test/runtime/ggml/BUCK index 2b2add96f39..05de9fa2b72 100644 --- a/backends/native/test/runtime/ggml/BUCK +++ b/backends/native/test/runtime/ggml/BUCK @@ -59,17 +59,32 @@ _VARIANTS = [ }, ] +[ + fbcode_target( + _kind = runtime.cxx_library, + name = "ggml_engine_host" + v["suffix"], + srcs = ["ggml_ptn_runner.cpp"], + link_whole = True, + visibility = ["//executorch/backends/native/test/..."], + exported_deps = ["//executorch/backends/native/test/runtime:engine_host"], + deps = ["//executorch/backends/native/runtime/engine:" + v["engine"]] + + v.get("deps", []), + external_deps = ["gflags"] + v.get("external_deps", []), + labels = v.get("labels", []), + **v.get("compat", {}) + ) + for v in _VARIANTS +] + [ fbcode_target( _kind = runtime.cxx_binary, name = "ggml_ptn_runner" + v["suffix"], - srcs = ["ggml_ptn_runner.cpp"], visibility = ["//executorch/backends/native/test/..."], deps = [ - "//executorch/backends/native/runtime/engine:" + v["engine"], + ":ggml_engine_host" + v["suffix"], "//executorch/backends/native/test/runtime:ptn_runner_main", - ] + v.get("deps", []), - external_deps = ["gflags"] + v.get("external_deps", []), + ], labels = v.get("labels", []), **v.get("compat", {}) ) diff --git a/backends/native/test/runtime/ggml/ggml_ptn_runner.cpp b/backends/native/test/runtime/ggml/ggml_ptn_runner.cpp index 6b725ee46f4..85336504930 100644 --- a/backends/native/test/runtime/ggml/ggml_ptn_runner.cpp +++ b/backends/native/test/runtime/ggml/ggml_ptn_runner.cpp @@ -4,7 +4,7 @@ // This source code is licensed under the BSD-style license found in the // LICENSE file in the root directory of this source tree. -// The ggml engine for ptn_runner_main.cpp. +// The ggml engine host for PTN test drivers. #include diff --git a/backends/native/test/runtime/vulkan/targets.bzl b/backends/native/test/runtime/vulkan/targets.bzl index afb7902c023..1e707ee89c0 100644 --- a/backends/native/test/runtime/vulkan/targets.bzl +++ b/backends/native/test/runtime/vulkan/targets.bzl @@ -41,12 +41,20 @@ def define_common_targets(): cmd = "mkdir -p $OUT && cp $(location fbsource//third-party/swiftshader/lib/linux-x64:libvk_swiftshader_so)/libvulkan.so.1 $OUT/libvk_swiftshader.so", ) + runtime.cxx_library( + name = "vulkan_engine_host", + srcs = ["vulkan_ptn_runner.cpp"], + link_whole = True, + visibility = ["//executorch/backends/native/test/..."], + exported_deps = ["//executorch/backends/native/test/runtime:engine_host"], + deps = ["//executorch/backends/native/runtime/vulkan:vulkan_engine"], + ) + runtime.cxx_binary( name = "vulkan_ptn_runner", - srcs = ["vulkan_ptn_runner.cpp"], visibility = ["//executorch/backends/native/test/..."], deps = [ - "//executorch/backends/native/runtime/vulkan:vulkan_engine", + ":vulkan_engine_host", "//executorch/backends/native/test/runtime:ptn_runner_main", "fbsource//third-party/swiftshader/lib/linux-x64:libvk_swiftshader_fbcode", ], diff --git a/backends/native/test/runtime/vulkan/vulkan_ptn_runner.cpp b/backends/native/test/runtime/vulkan/vulkan_ptn_runner.cpp index b4695b6a784..3f8b9e76ee0 100644 --- a/backends/native/test/runtime/vulkan/vulkan_ptn_runner.cpp +++ b/backends/native/test/runtime/vulkan/vulkan_ptn_runner.cpp @@ -4,7 +4,7 @@ // This source code is licensed under the BSD-style license found in the // LICENSE file in the root directory of this source tree. -// The Vulkan engine for ptn_runner_main.cpp. +// The Vulkan engine host for PTN test drivers. #include #include From b91949b3942732536816d98dea09b839da58deb2 Mon Sep 17 00:00:00 2001 From: Digant Desai Date: Tue, 3 Nov 2026 06:24:22 -0800 Subject: [PATCH 111/128] [executorch][native] Record op packages with bundled golden and precise samples Pull Request resolved: https://github.com/pytorch/executorch/pull/24357 Engines need op-level reference data that doesn't depend on any engine or device. `op_samples` writes one .ptn per op and quantization scheme, bundling `golden` (eager) and `precise` (float64, weights dequantized, activations unquantized) samples over the same inputs. Linear is the first op. Schemes are float, int4wo g32/g128 and 8da4w g32/g128, each with f32, f16 and bf16 methods; int8 weight-only is left out because no native engine runs it. Linear cases are the backend test suite's models, reused through a stand-in runner, plus decode, prefill, large-K, bias-free leading-batch, outlier-channel and zero-row cases. With CMake, the `native_op_samples` target writes them; it is not in ALL, so a plain build never needs torch. Ops and schemes are independent axes. An op is an `Op` in `ops()`, a function returning `Case`s plus the dtypes to record them in; a scheme is a `Scheme` in `schemes()` that decides which cases it fits, quantizes a model and decodes its weights. `generate` records every op under every scheme, one spawned process per package, and writes no package when a scheme fits none of an op's cases. `cases.py` holds the case type and the backend test suite adapter, `schemes.py` the quantization, and `record.py` the packaging, which knows neither torchao nor any op. Authored with Claude Code. @bypass-github-export-checks @bypass-github-pytorch-ci-checks @bypass-github-executorch-ci-checks ghstack-source-id: 463657757 @exported-using-ghexport Differential Revision: [D128254877](https://our.internmc.facebook.com/intern/diff/D128254877/) --- backends/native/test/CMakeLists.txt | 18 ++++ backends/native/test/samples/BUCK | 50 +++++++++++ backends/native/test/samples/cases.py | 87 +++++++++++++++++++ backends/native/test/samples/generate.py | 50 +++++++++++ backends/native/test/samples/linear.py | 36 ++++++++ backends/native/test/samples/ops.py | 24 ++++++ backends/native/test/samples/record.py | 92 +++++++++++++++++++++ backends/native/test/samples/schemes.py | 91 ++++++++++++++++++++ backends/native/test/samples/test_record.py | 90 ++++++++++++++++++++ 9 files changed, 538 insertions(+) create mode 100644 backends/native/test/samples/BUCK create mode 100644 backends/native/test/samples/cases.py create mode 100644 backends/native/test/samples/generate.py create mode 100644 backends/native/test/samples/linear.py create mode 100644 backends/native/test/samples/ops.py create mode 100644 backends/native/test/samples/record.py create mode 100644 backends/native/test/samples/schemes.py create mode 100644 backends/native/test/samples/test_record.py diff --git a/backends/native/test/CMakeLists.txt b/backends/native/test/CMakeLists.txt index 73ea13f321c..eb404c4cba9 100644 --- a/backends/native/test/CMakeLists.txt +++ b/backends/native/test/CMakeLists.txt @@ -29,6 +29,24 @@ target_compile_options( $<$>:-fexceptions> ) +# Op packages with bundled samples, one per op and quantization scheme. +set(_native_op_samples ${CMAKE_CURRENT_BINARY_DIR}/op_samples) +file(GLOB _native_op_samples_srcs CONFIGURE_DEPENDS + ${CMAKE_CURRENT_SOURCE_DIR}/samples/*.py +) +add_custom_command( + OUTPUT ${_native_op_samples}.stamp + COMMAND + ${CMAKE_COMMAND} -E env "PYTHONPATH=${EXECUTORCH_ROOT}/src" + ${PYTHON_EXECUTABLE} -m executorch.backends.native.test.samples.generate + --out ${_native_op_samples} + COMMAND ${CMAKE_COMMAND} -E touch ${_native_op_samples}.stamp + DEPENDS ${_native_op_samples_srcs} + ${EXECUTORCH_ROOT}/backends/test/suite/operators/test_linear.py + VERBATIM +) +add_custom_target(native_op_samples DEPENDS ${_native_op_samples}.stamp) + if(EXECUTORCH_BUILD_NATIVE_VULKAN) add_executable( native_vulkan_passes_test diff --git a/backends/native/test/samples/BUCK b/backends/native/test/samples/BUCK new file mode 100644 index 00000000000..97b4b66b7db --- /dev/null +++ b/backends/native/test/samples/BUCK @@ -0,0 +1,50 @@ +load("@fbcode_macros//build_defs:build_file_migration.bzl", "fbcode_target") +load("@fbsource//xplat/executorch/build:runtime_wrapper.bzl", "runtime") + +oncall("executorch") + +fbcode_target( + _kind = runtime.python_library, + name = "record", + srcs = [ + "cases.py", + "linear.py", + "ops.py", + "record.py", + "schemes.py", + ], + deps = [ + "//caffe2:torch", + "//executorch/backends/test/suite/operators:test_operators", + "//executorch/exir/native:native", + "//pytorch/ao:torchao", + ], +) + +fbcode_target( + _kind = runtime.python_binary, + name = "generate", + srcs = ["generate.py"], + main_function = "executorch.backends.native.test.samples.generate.main", + deps = [":record"], +) + +fbcode_target( + _kind = runtime.python_test, + name = "test_record", + srcs = ["test_record.py"], + deps = [ + ":record", + "//caffe2:torch", + "//executorch/exir/native:native", + ], +) + +# Op packages with bundled samples, one per op and quantization scheme. +fbcode_target( + _kind = runtime.genrule, + name = "op_samples", + out = "op_samples", + cmd = "$(exe :generate) --out $OUT", + visibility = ["//executorch/backends/native/test/..."], +) diff --git a/backends/native/test/samples/cases.py b/backends/native/test/samples/cases.py new file mode 100644 index 00000000000..b08baba963a --- /dev/null +++ b/backends/native/test/samples/cases.py @@ -0,0 +1,87 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Op cases: a model and the inputs of each sample it is recorded with.""" + +import inspect +from dataclasses import dataclass +from types import ModuleType + +import torch + +Inputs = tuple[torch.Tensor, ...] + +# Caps a suite case's input and output elements over its samples, keeping at +# least one sample: a large output alone has enough elements for statistics. +_ELEMENT_BUDGET = 1 << 16 + + +@dataclass +class Case: + name: str + model: torch.nn.Module + # Positional float32 inputs of each sample; the count may vary by case. + inputs: list[Inputs] + + +def outputs(result) -> list[torch.Tensor]: + return list(result) if isinstance(result, (tuple, list)) else [result] + + +def draws(count: int, *shapes: tuple[int, ...]) -> list[Inputs]: + """`count` samples of standard-normal inputs of the given shapes, from a fixed + seed, so equal calls return equal samples.""" + generator = torch.Generator().manual_seed(0) + return [ + tuple(torch.randn(shape, generator=generator) for shape in shapes) + for _ in range(count) + ] + + +class _SuiteRunner: + """Stands in for the backend test suite's runner, keeping what a test lowers.""" + + _flow = None + + def __init__(self) -> None: + self.calls: list[tuple[torch.nn.Module, Inputs]] = [] + + def lower_and_run_model(self, model, inputs, **_) -> None: + self.calls.append((model, tuple(inputs))) + + +def suite_cases(module: ModuleType, samples: int) -> list[Case]: + """One case per model a backend test suite operator module lowers, with + inputs from up to `samples` runs under different seeds.""" + cases = [] + for test_name, test in sorted(vars(module).items()): + if not test_name.startswith("test_"): + continue + runs = [] + for seed in range(samples): + torch.manual_seed(seed) + runner = _SuiteRunner() + if "dtype" in inspect.signature(test).parameters: + test(runner, torch.float32) + else: + test(runner) + runs.append(runner.calls) + lowered = [[(type(m), [x.shape for x in xs]) for m, xs in run] for run in runs] + if any(seed_lowered != lowered[0] for seed_lowered in lowered): + raise ValueError(f"{test_name} lowers different models across seeds") + for i, (model, inputs) in enumerate(runs[0]): + name = f"{test_name.removeprefix('test_')}_{i}" + try: + results = outputs(model(*inputs)) + except RuntimeError as e: + e.add_note(f"while recording suite case {name}") + raise + numel = sum(t.numel() for t in (*inputs, *results)) + count = max(1, min(samples, _ELEMENT_BUDGET // max(1, numel))) + cases.append(Case(name, model, [run[i][1] for run in runs[:count]])) + if not cases: + raise ValueError(f"no test in {module.__name__} lowers a model") + return cases diff --git a/backends/native/test/samples/generate.py b/backends/native/test/samples/generate.py new file mode 100644 index 00000000000..665a17aa22b --- /dev/null +++ b/backends/native/test/samples/generate.py @@ -0,0 +1,50 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Write _.ptn for each op and quantization scheme that fits a case +of the op.""" + +import argparse +import logging +import multiprocessing +from concurrent.futures import ProcessPoolExecutor +from pathlib import Path + +from executorch.backends.native.test.samples.ops import ops +from executorch.backends.native.test.samples.record import record +from executorch.backends.native.test.samples.schemes import schemes + + +def _record(name: str, scheme: str, out: Path) -> None: + logging.basicConfig(level=logging.INFO, force=True) + op = ops()[name] + record(op.cases(), schemes()[scheme], out / f"{name}_{scheme}.ptn", op.dtypes) + + +def main() -> None: + all_ops, all_schemes = ops(), schemes() + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--out", type=Path, required=True) + parser.add_argument("--ops", nargs="+", choices=all_ops, default=list(all_ops)) + parser.add_argument( + "--schemes", nargs="+", choices=all_schemes, default=list(all_schemes) + ) + args = parser.parse_args() + args.out.mkdir(parents=True, exist_ok=True) + # Forked workers can't use the CUDA state torch may have initialized here. + spawn = multiprocessing.get_context("spawn") + with ProcessPoolExecutor(mp_context=spawn) as pool: + jobs = [ + pool.submit(_record, name, scheme, args.out) + for name in args.ops + for scheme in args.schemes + ] + for job in jobs: + job.result() + + +if __name__ == "__main__": + main() diff --git a/backends/native/test/samples/linear.py b/backends/native/test/samples/linear.py new file mode 100644 index 00000000000..24c39fe2ccc --- /dev/null +++ b/backends/native/test/samples/linear.py @@ -0,0 +1,36 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Linear cases: the backend test suite's, and shapes and inputs that stress +accumulation and quantization.""" + +import torch +from executorch.backends.native.test.samples.cases import Case, draws, suite_cases +from executorch.backends.test.suite.operators import test_linear + + +def cases() -> list[Case]: + suite = suite_cases(test_linear, samples=4) + torch.manual_seed(0) + # One channel far above the rest, as in LLM activations. + outlier = draws(4, (16, 256)) + for (x,) in outlier: + x[:, 3] *= 32 + zero_row = draws(2, (8, 256)) + for (x,) in zero_row: + x[2] = 0 + return suite + [ + Case("decode", torch.nn.Linear(2048, 512), draws(8, (1, 2048))), + Case("prefill", torch.nn.Linear(1024, 256), draws(2, (64, 1024))), + Case("large_k", torch.nn.Linear(8192, 32), draws(4, (4, 8192))), + Case( + "leading_batch_no_bias", + torch.nn.Linear(256, 96, bias=False), + draws(4, (2, 7, 256)), + ), + Case("outlier_channel", torch.nn.Linear(256, 128), outlier), + Case("zero_row", torch.nn.Linear(256, 64), zero_row), + ] diff --git a/backends/native/test/samples/ops.py b/backends/native/test/samples/ops.py new file mode 100644 index 00000000000..16332b665ee --- /dev/null +++ b/backends/native/test/samples/ops.py @@ -0,0 +1,24 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Recorded ops, keyed by the name their packages are written under.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from executorch.backends.native.test.samples import linear +from executorch.backends.native.test.samples.cases import Case +from executorch.backends.native.test.samples.record import DTYPES + + +@dataclass(frozen=True) +class Op: + cases: Callable[[], list[Case]] + dtypes: tuple[str, ...] = tuple(DTYPES) + + +def ops() -> dict[str, Op]: + return {"linear": Op(linear.cases)} diff --git a/backends/native/test/samples/record.py b/backends/native/test/samples/record.py new file mode 100644 index 00000000000..6846890f081 --- /dev/null +++ b/backends/native/test/samples/record.py @@ -0,0 +1,92 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Record op cases into .ptn packages with bundled samples. + +A package holds one method per case and dtype under one quantization scheme, +and two sample sets over the same inputs: `golden`, the eager outputs, and +`precise`, the same math in float64 with weights dequantized and activations +unquantized, floating outputs stored as float32. Nothing in it depends on an +engine or a device: consumers judge an engine by its distance from `precise` +relative to eager's. +""" + +import copy +import logging +from collections.abc import Iterable +from pathlib import Path + +import torch +from executorch.backends.native.test.samples.cases import Case, Inputs, outputs +from executorch.backends.native.test.samples.schemes import Scheme +from executorch.exir.native import Call, Sample, SampleSet, to_native, write_samples +from torch.func import functional_call + +logger: logging.Logger = logging.getLogger(__name__) + +DTYPES = {"f32": torch.float32, "f16": torch.float16, "bf16": torch.bfloat16} + + +class _Method(torch.nn.ModuleDict): + """One model keyed by its method name, keeping constant FQNs unique across a + package's methods.""" + + def forward(self, *args): + (model,) = self.values() + return model(*args) + + +def _cast(inputs: Inputs, dtype: torch.dtype) -> Inputs: + return tuple(t.to(dtype) if t.is_floating_point() else t for t in inputs) + + +def record( + cases: list[Case], + scheme: Scheme, + path: Path, + dtypes: Iterable[str] = tuple(DTYPES), +) -> None: + """Writes no package, removing any at `path`, when the scheme fits no case.""" + programs, golden, precise = {}, [], [] + for case in cases: + if not scheme.fits(case.model): + logger.info(f"{path.name}: {case.name} does not fit the scheme") + continue + for suffix in dtypes: + name = f"{case.name}_{suffix}" + if name in programs: + raise ValueError(f"{path.name}: duplicate method {name}") + dtype = DTYPES[suffix] + model = copy.deepcopy(case.model).to(dtype).eval() + scheme.quantize(model) + method = _Method({name: model}) + state = { + k: scheme.decode(v).double() for k, v in method.state_dict().items() + } + inputs = [_cast(x, dtype) for x in case.inputs] + programs[name] = torch.export.export(method, inputs[0]) + with torch.no_grad(): + for x in inputs: + golden.append(Sample([Call(name, list(x), outputs(method(*x)))])) + ref = functional_call(method, state, _cast(x, torch.float64)) + ref = [ + t.float() if t.is_floating_point() else t for t in outputs(ref) + ] + precise.append(Sample([Call(name, list(x), ref)])) + if not programs: + logger.info(f"{path.name}: no case fits the scheme, not written") + path.unlink(missing_ok=True) + return + to_native(programs).save(str(path)) + source = f"torch {torch.__version__}" + write_samples(str(path), "golden", SampleSet(golden, "eager", source)) + write_samples( + str(path), + "precise", + SampleSet( + precise, "float64, weights dequantized, activations unquantized", source + ), + ) diff --git a/backends/native/test/samples/schemes.py b/backends/native/test/samples/schemes.py new file mode 100644 index 00000000000..8966e7facad --- /dev/null +++ b/backends/native/test/samples/schemes.py @@ -0,0 +1,91 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Quantization schemes. Every op is recorded under every scheme, keeping the +cases the scheme fits.""" + +from dataclasses import dataclass + +import torch +from torchao.core.config import AOBaseConfig +from torchao.quantization import ( + Int8DynamicActivationIntxWeightConfig, + IntxWeightOnlyConfig, + quantize_, +) +from torchao.quantization.granularity import PerGroup +from torchao.utils import TorchAOBaseTensor + + +class Scheme: + """No quantization.""" + + def fits(self, model: torch.nn.Module) -> bool: + return True + + def quantize(self, model: torch.nn.Module) -> None: + pass + + def decode(self, value: torch.Tensor) -> torch.Tensor: + """A state tensor of a quantized model as a plain tensor.""" + return value + + +def _fits(model: torch.nn.Module, group: int | None) -> bool: + """Whether `model` has an nn.Linear and every one's in_features split into + groups of `group`, or form one group when it is None.""" + linears = [m for m in model.modules() if isinstance(m, torch.nn.Linear)] + for m in linears: + size = m.in_features if group is None else group + # torchao reduces a one-element-group weight to a single scale. + if size < 2 or m.in_features % size: + return False + return bool(linears) + + +@dataclass(frozen=True) +class Torchao(Scheme): + """A torchao config applied to every nn.Linear.""" + + config: AOBaseConfig + + def fits(self, model: torch.nn.Module) -> bool: + granularity = ( + self.config.granularity + if isinstance(self.config, IntxWeightOnlyConfig) + else self.config.weight_granularity + ) + return _fits( + model, granularity.group_size if isinstance(granularity, PerGroup) else None + ) + + def quantize(self, model: torch.nn.Module) -> None: + quantize_(model, self.config) + + def decode(self, value: torch.Tensor) -> torch.Tensor: + return value.dequantize() if isinstance(value, TorchAOBaseTensor) else value + + +def schemes() -> dict[str, Scheme]: + return { + "float": Scheme(), + "int4wo_g32": Torchao( + IntxWeightOnlyConfig(weight_dtype=torch.int4, granularity=PerGroup(32)) + ), + "int4wo_g128": Torchao( + IntxWeightOnlyConfig(weight_dtype=torch.int4, granularity=PerGroup(128)) + ), + "8da4w_g32": Torchao( + Int8DynamicActivationIntxWeightConfig( + weight_dtype=torch.int4, weight_granularity=PerGroup(32) + ) + ), + "8da4w_g128": Torchao( + Int8DynamicActivationIntxWeightConfig( + weight_dtype=torch.int4, weight_granularity=PerGroup(128) + ) + ), + } diff --git a/backends/native/test/samples/test_record.py b/backends/native/test/samples/test_record.py new file mode 100644 index 00000000000..391ecd9b52a --- /dev/null +++ b/backends/native/test/samples/test_record.py @@ -0,0 +1,90 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import tempfile +import unittest +from pathlib import Path +from types import ModuleType + +import torch +import torch.nn.functional as F +from executorch.backends.native.test.samples.cases import Case, draws, suite_cases +from executorch.backends.native.test.samples.record import record +from executorch.backends.native.test.samples.schemes import Scheme, schemes +from executorch.exir.native import read_samples + + +class RecordTest(unittest.TestCase): + def test_suite_cases_raises_on_a_model_eager_rejects(self) -> None: + module = ModuleType("suite") + module.test_mismatch = lambda runner: runner.lower_and_run_model( + torch.nn.Linear(4, 2), (torch.randn(1, 3),) + ) + with self.assertRaises(RuntimeError) as raised: + suite_cases(module, samples=1) + self.assertIn( + "while recording suite case mismatch_0", raised.exception.__notes__ + ) + + def test_suite_cases_raises_when_seeds_lower_different_shapes(self) -> None: + module = ModuleType("suite") + sizes = iter([2, 3]) + module.test_varying = lambda runner: runner.lower_and_run_model( + torch.nn.Identity(), (torch.randn(next(sizes)),) + ) + with self.assertRaises(ValueError): + suite_cases(module, samples=2) + + def test_suite_cases_raises_when_no_test_lowers_a_model(self) -> None: + with self.assertRaises(ValueError): + suite_cases(ModuleType("suite"), samples=1) + + def test_record_raises_on_duplicate_case_names(self) -> None: + case = Case("same", torch.nn.Linear(2, 2), draws(1, (1, 2))) + with tempfile.TemporaryDirectory() as tmp: + with self.assertRaises(ValueError): + record([case, case], Scheme(), Path(tmp) / "dup.ptn") + + def test_record_writes_no_package_when_the_scheme_fits_no_case(self) -> None: + case = Case("relu", torch.nn.ReLU(), draws(1, (1, 32))) + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "relu.ptn" + path.write_bytes(b"stale") + record([case], schemes()["int4wo_g32"], path) + self.assertFalse(path.exists()) + + def test_record_golden_is_eager_and_precise_is_float64(self) -> None: + model = torch.nn.Linear(16, 4) + (x,) = draws(1, (3, 16))[0] + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "linear.ptn" + record([Case("linear", model, [(x,)])], Scheme(), path, dtypes=["bf16"]) + sets = read_samples(str(path)) + x, w, b = (t.detach().bfloat16() for t in (x, model.weight, model.bias)) + torch.testing.assert_close( + sets["golden"].samples[0].calls[0].outputs[0], + F.linear(x, w, b), + rtol=0, + atol=0, + ) + torch.testing.assert_close( + sets["precise"].samples[0].calls[0].outputs[0], + F.linear(x.double(), w.double(), b.double()).float(), + rtol=0, + atol=0, + ) + + def test_record_keeps_integer_outputs_precise(self) -> None: + class Argmax(torch.nn.Module): + def forward(self, x: torch.Tensor) -> torch.Tensor: + return x.argmax(-1) + + case = Case("argmax", Argmax(), draws(1, (2, 8))) + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "argmax.ptn" + record([case], Scheme(), path, dtypes=["f32"]) + precise = read_samples(str(path))["precise"].samples + self.assertEqual(precise[0].calls[0].outputs[0].dtype, torch.int64) From 0e7dd3139aa4d72f87949097ddb419a32d810bbf Mon Sep 17 00:00:00 2001 From: Digant Desai Date: Tue, 3 Nov 2026 06:24:23 -0800 Subject: [PATCH 112/128] [executorch][native] Pin eager kernels when generating op samples Pull Request resolved: https://github.com/pytorch/executorch/pull/24358 Eager golden outputs differed by an ulp or two between generating hosts, because MKL and oneDNN pick kernels by ISA and MKL splits work by thread count. The `op_samples` genrule and the `native_op_samples` CMake target now pin MKL to its reproducible path, oneDNN and ATen to AVX2, and OpenMP and MKL to one thread. `MKL_NUM_THREADS` is set as well as `OMP_NUM_THREADS` so a host's own value cannot override it. Authored with Claude Code. @bypass-github-export-checks @bypass-github-pytorch-ci-checks @bypass-github-executorch-ci-checks ghstack-source-id: 463657753 @exported-using-ghexport Differential Revision: [D128254894](https://our.internmc.facebook.com/intern/diff/D128254894/) --- backends/native/test/CMakeLists.txt | 11 +++++++---- backends/native/test/samples/BUCK | 6 ++++-- backends/native/test/samples/generate.py | 7 ++++++- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/backends/native/test/CMakeLists.txt b/backends/native/test/CMakeLists.txt index eb404c4cba9..4e9c7b28837 100644 --- a/backends/native/test/CMakeLists.txt +++ b/backends/native/test/CMakeLists.txt @@ -29,7 +29,9 @@ target_compile_options( $<$>:-fexceptions> ) -# Op packages with bundled samples, one per op and quantization scheme. +# Op packages with bundled samples, one per op and quantization scheme. The +# environment pins the eager kernels' ISA and threads, so the golden is the same +# on any x86 host with AVX2. Other hosts can differ. set(_native_op_samples ${CMAKE_CURRENT_BINARY_DIR}/op_samples) file(GLOB _native_op_samples_srcs CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/samples/*.py @@ -37,9 +39,10 @@ file(GLOB _native_op_samples_srcs CONFIGURE_DEPENDS add_custom_command( OUTPUT ${_native_op_samples}.stamp COMMAND - ${CMAKE_COMMAND} -E env "PYTHONPATH=${EXECUTORCH_ROOT}/src" - ${PYTHON_EXECUTABLE} -m executorch.backends.native.test.samples.generate - --out ${_native_op_samples} + ${CMAKE_COMMAND} -E env MKL_CBWR=COMPATIBLE ONEDNN_MAX_CPU_ISA=AVX2 + ATEN_CPU_CAPABILITY=avx2 OMP_NUM_THREADS=1 MKL_NUM_THREADS=1 + "PYTHONPATH=${EXECUTORCH_ROOT}/src" ${PYTHON_EXECUTABLE} -m + executorch.backends.native.test.samples.generate --out ${_native_op_samples} COMMAND ${CMAKE_COMMAND} -E touch ${_native_op_samples}.stamp DEPENDS ${_native_op_samples_srcs} ${EXECUTORCH_ROOT}/backends/test/suite/operators/test_linear.py diff --git a/backends/native/test/samples/BUCK b/backends/native/test/samples/BUCK index 97b4b66b7db..48e83efc2d9 100644 --- a/backends/native/test/samples/BUCK +++ b/backends/native/test/samples/BUCK @@ -40,11 +40,13 @@ fbcode_target( ], ) -# Op packages with bundled samples, one per op and quantization scheme. +# Op packages with bundled samples, one per op and quantization scheme. The +# environment pins the eager kernels' ISA and threads, so the golden is the same +# on any x86 host with AVX2. Other hosts can differ. fbcode_target( _kind = runtime.genrule, name = "op_samples", out = "op_samples", - cmd = "$(exe :generate) --out $OUT", + cmd = "MKL_CBWR=COMPATIBLE ONEDNN_MAX_CPU_ISA=AVX2 ATEN_CPU_CAPABILITY=avx2 OMP_NUM_THREADS=1 MKL_NUM_THREADS=1 $(exe :generate) --out $OUT", visibility = ["//executorch/backends/native/test/..."], ) diff --git a/backends/native/test/samples/generate.py b/backends/native/test/samples/generate.py index 665a17aa22b..f98fe7e91cb 100644 --- a/backends/native/test/samples/generate.py +++ b/backends/native/test/samples/generate.py @@ -5,7 +5,12 @@ # LICENSE file in the root directory of this source tree. """Write _.ptn for each op and quantization scheme that fits a case -of the op.""" +of the op. + +Golden outputs depend on the eager kernels' ISA and threads; the op_samples +genrule and the native_op_samples CMake target pin them, so build one of those +rather than running this module to reproduce their packages. +""" import argparse import logging From 7b4699be7c983d49fabe836dfc07f7e9edc63e6e Mon Sep 17 00:00:00 2001 From: Digant Desai Date: Tue, 3 Nov 2026 06:24:24 -0800 Subject: [PATCH 113/128] [executorch][native] Judge engine outputs against golden and precise samples Pull Request resolved: https://github.com/pytorch/executorch/pull/24359 A fixed tolerance either hides engine bugs or fails legitimate numerics, so an engine is judged by its distance from `precise` relative to eager's. `OutputCheck` bounds RMSE and max error by multiples of eager's plus `rtol`, and the fitted absolute and relative bias likewise, plus `sigmas` standard errors. Non-finite values must equal golden. CMake builds it and its test too. Authored with Claude Code. @bypass-github-export-checks @bypass-github-pytorch-ci-checks @bypass-github-executorch-ci-checks ghstack-source-id: 463657743 @exported-using-ghexport Differential Revision: [D128254878](https://our.internmc.facebook.com/intern/diff/D128254878/) --- backends/native/test/CMakeLists.txt | 15 + backends/native/test/samples/BUCK | 16 ++ backends/native/test/samples/OutputCheck.cpp | 198 ++++++++++++++ backends/native/test/samples/OutputCheck.h | 93 +++++++ .../native/test/samples/test_output_check.cpp | 257 ++++++++++++++++++ 5 files changed, 579 insertions(+) create mode 100644 backends/native/test/samples/OutputCheck.cpp create mode 100644 backends/native/test/samples/OutputCheck.h create mode 100644 backends/native/test/samples/test_output_check.cpp diff --git a/backends/native/test/CMakeLists.txt b/backends/native/test/CMakeLists.txt index 4e9c7b28837..f6a72ffde51 100644 --- a/backends/native/test/CMakeLists.txt +++ b/backends/native/test/CMakeLists.txt @@ -29,6 +29,21 @@ target_compile_options( $<$>:-fexceptions> ) +# Judges an engine's outputs against a package's golden and precise samples. +add_library(native_output_check STATIC samples/OutputCheck.cpp) +target_link_libraries(native_output_check PUBLIC native_package) +add_executable(native_output_check_test samples/test_output_check.cpp) +target_link_libraries( + native_output_check_test PRIVATE native_output_check GTest::gtest_main +) +add_test(NAME native_output_check_test COMMAND native_output_check_test) +foreach(target native_output_check native_output_check_test) + target_compile_options( + ${target} PRIVATE $<$:/EHsc> + $<$>:-fexceptions> + ) +endforeach() + # Op packages with bundled samples, one per op and quantization scheme. The # environment pins the eager kernels' ISA and threads, so the golden is the same # on any x86 host with AVX2. Other hosts can differ. diff --git a/backends/native/test/samples/BUCK b/backends/native/test/samples/BUCK index 48e83efc2d9..a59019b033c 100644 --- a/backends/native/test/samples/BUCK +++ b/backends/native/test/samples/BUCK @@ -50,3 +50,19 @@ fbcode_target( cmd = "MKL_CBWR=COMPATIBLE ONEDNN_MAX_CPU_ISA=AVX2 ATEN_CPU_CAPABILITY=avx2 OMP_NUM_THREADS=1 MKL_NUM_THREADS=1 $(exe :generate) --out $OUT", visibility = ["//executorch/backends/native/test/..."], ) + +# Judges an engine's outputs against a package's golden and precise samples. +fbcode_target( + _kind = runtime.cxx_library, + name = "output_check", + srcs = ["OutputCheck.cpp"], + exported_headers = ["OutputCheck.h"], + exported_deps = ["//executorch/backends/native/runtime/deserialize:samples"], +) + +fbcode_target( + _kind = runtime.cxx_test, + name = "output_check_test", + srcs = ["test_output_check.cpp"], + deps = [":output_check"], +) diff --git a/backends/native/test/samples/OutputCheck.cpp b/backends/native/test/samples/OutputCheck.cpp new file mode 100644 index 00000000000..f60611a847f --- /dev/null +++ b/backends/native/test/samples/OutputCheck.cpp @@ -0,0 +1,198 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. +// All rights reserved. +// +// This source code is licensed under the BSD-style license found in the +// LICENSE file in the root directory of this source tree. + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace ptn::testing { +namespace { + +uint32_t load_u16(const uint8_t* bytes) { + return static_cast(bytes[0]) | + (static_cast(bytes[1]) << 8); +} + +float half_to_float(const uint8_t* bytes) { + const uint32_t h = load_u16(bytes); + const uint32_t sign = (h & 0x8000u) << 16; + const uint32_t exponent = (h >> 10) & 0x1Fu; + const uint32_t mantissa = h & 0x3FFu; + if (exponent == 0) { + const float magnitude = std::ldexp(static_cast(mantissa), -24); + return sign != 0 ? -magnitude : magnitude; + } + if (exponent == 0x1Fu) { + return std::bit_cast(sign | 0x7F800000u | (mantissa << 13)); + } + return std::bit_cast( + sign | ((exponent + 112) << 23) | (mantissa << 13)); +} + +bool same(double a, double b) { + return a == b || (std::isnan(a) && std::isnan(b)); +} + +std::string g3(double v) { + char text[32]; + std::snprintf(text, sizeof(text), "%.3g", v); + return text; +} + +} // namespace + +std::vector to_float(const SampleTensor& t) { + const size_t numel = t.bytes.size() / element_size(t.dtype); + std::vector out(numel); + const uint8_t* bytes = t.bytes.data(); + switch (t.dtype) { + case kFloat: + std::memcpy(out.data(), bytes, numel * sizeof(float)); + break; + case kDouble: + for (size_t i = 0; i < numel; ++i) { + double v; + std::memcpy(&v, bytes + i * sizeof(double), sizeof(double)); + out[i] = static_cast(v); + } + break; + case kHalf: + for (size_t i = 0; i < numel; ++i) { + out[i] = half_to_float(bytes + 2 * i); + } + break; + case kBFloat16: + for (size_t i = 0; i < numel; ++i) { + out[i] = std::bit_cast(load_u16(bytes + 2 * i) << 16); + } + break; + case kByte: + case kChar: + case kShort: + case kInt: + case kLong: + case kBool: + case kUInt16: + case kUInt32: + case kUInt64: + throw std::invalid_argument( + std::string("to_float: unsupported dtype ") + + scalar_type_name(t.dtype)); + } + return out; +} + +// Welford co-moments, stable when the means are large against the spread. +void OutputCheck::Fit::add(double p, double d) { + ++n; + const double dp = p - mean_p; + const double dd = d - mean_d; + mean_p += dp / n; + mean_d += dd / n; + cpp += dp * (p - mean_p); + cpd += dp * (d - mean_d); + cdd += dd * (d - mean_d); + sum_dd += d * d; + max_abs = std::max(max_abs, std::abs(d)); +} + +ErrorStats OutputCheck::Fit::stats() const { + ErrorStats s{.count = n, .max_abs = max_abs}; + if (n == 0) { + return s; + } + const bool sloped = cpp > 0.0; + s.rmse = std::sqrt(sum_dd / n); + s.rel_bias = sloped ? cpd / cpp : 0.0; + s.abs_bias = mean_d - s.rel_bias * mean_p; + const int64_t dof = n - (sloped ? 2 : 1); + const double residual = dof > 0 ? std::max(0.0, cdd - s.rel_bias * cpd) / dof + : std::numeric_limits::infinity(); + s.abs_bias_se = + std::sqrt(residual * (1.0 / n + (sloped ? mean_p * mean_p / cpp : 0.0))); + s.rel_bias_se = sloped ? std::sqrt(residual / cpp) : 0.0; + return s; +} + +void OutputCheck::add( + std::span got, + const SampleTensor& golden, + const SampleTensor& precise) { + const std::vector want = to_float(golden); + const std::vector ref = to_float(precise); + if (got.size() != want.size() || got.size() != ref.size()) { + throw std::invalid_argument( + "OutputCheck: " + std::to_string(got.size()) + " values against " + + std::to_string(want.size()) + " golden and " + + std::to_string(ref.size()) + " precise"); + } + for (size_t i = 0; i < got.size(); ++i) { + const double e = got[i]; + const double g = want[i]; + const double p = ref[i]; + if (!std::isfinite(e) || !std::isfinite(g) || !std::isfinite(p)) { + if (!same(e, g)) { + ++nonfinite_mismatches_; + } + continue; + } + engine_.add(p, e - p); + eager_.add(p, g - p); + sum_pp_ += p * p; + max_p_ = std::max(max_p_, std::abs(p)); + } +} + +std::vector OutputCheck::failures(const Policy& policy) const { + const ErrorStats e = engine(); + const ErrorStats g = eager(); + const double rms_p = e.count > 0 ? std::sqrt(sum_pp_ / e.count) : 0.0; + std::vector out; + const auto bound = [&](const char* name, double value, double limit) { + if (!(value <= limit)) { + out.push_back(std::string(name) + ": " + g3(value) + " > " + g3(limit)); + } + }; + bound("rmse", e.rmse, policy.rmse_ratio * g.rmse + policy.rtol * rms_p); + bound( + "max_abs", + e.max_abs, + policy.max_ratio * g.max_abs + policy.rtol * max_p_); + bound( + "abs_bias", + std::abs(e.abs_bias), + policy.rmse_ratio * std::abs(g.abs_bias) + policy.rtol * rms_p + + policy.sigmas * e.abs_bias_se); + bound( + "rel_bias", + std::abs(e.rel_bias), + policy.rmse_ratio * std::abs(g.rel_bias) + policy.rtol + + policy.sigmas * e.rel_bias_se); + if (nonfinite_mismatches_ > 0) { + out.push_back( + "nonfinite: " + std::to_string(nonfinite_mismatches_) + + " values differ from golden where a value is NaN or infinite"); + } + return out; +} + +std::string OutputCheck::summary() const { + const ErrorStats e = engine(); + const ErrorStats g = eager(); + return "n " + std::to_string(e.count) + ", engine (eager): rmse " + + g3(e.rmse) + " (" + g3(g.rmse) + "), max_abs " + g3(e.max_abs) + " (" + + g3(g.max_abs) + "), abs_bias " + g3(e.abs_bias) + " +-" + + g3(e.abs_bias_se) + " (" + g3(g.abs_bias) + "), rel_bias " + + g3(e.rel_bias) + " +-" + g3(e.rel_bias_se) + " (" + g3(g.rel_bias) + ")"; +} + +} // namespace ptn::testing diff --git a/backends/native/test/samples/OutputCheck.h b/backends/native/test/samples/OutputCheck.h new file mode 100644 index 00000000000..957cca691c3 --- /dev/null +++ b/backends/native/test/samples/OutputCheck.h @@ -0,0 +1,93 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. +// All rights reserved. +// +// This source code is licensed under the BSD-style license found in the +// LICENSE file in the root directory of this source tree. + +#pragma once + +#include +#include +#include +#include + +#include + +namespace ptn::testing { + +// Values of a Float, Half, BFloat16 or Double sample tensor. Throws +// std::invalid_argument for any other dtype. +std::vector to_float(const SampleTensor& t); + +// Error d = x - precise, fitted by least squares as +// d = abs_bias + rel_bias * precise; the _se fields are the fit's standard +// errors. +struct ErrorStats { + int64_t count = 0; + double rmse = 0.0; + double max_abs = 0.0; + double abs_bias = 0.0; + double abs_bias_se = 0.0; + double rel_bias = 0.0; + double rel_bias_se = 0.0; +}; + +// Bounds on an engine's error statistics: eager's times rmse_ratio (max_ratio +// for max_abs), plus rtol times the precise outputs' RMS (their max for +// max_abs, 1 for rel_bias). The fitted biases may also deviate by `sigmas` +// standard errors. +struct Policy { + double rmse_ratio = 3.0; + double max_ratio = 4.0; + double rtol = 1e-5; + double sigmas = 6.0; +}; + +// One output of a method, accumulated over its samples. Statistics cover the +// positions where the engine, golden and precise values are all finite; +// elsewhere the engine must equal golden, NaN matching NaN. +class OutputCheck { + public: + // Throws std::invalid_argument when the element counts differ. + void add( + std::span got, + const SampleTensor& golden, + const SampleTensor& precise); + + ErrorStats engine() const { + return engine_.stats(); + } + ErrorStats eager() const { + return eager_.stats(); + } + + // One ": " message per violated rule, among rmse, max_abs, + // abs_bias, rel_bias and nonfinite; empty when the engine passes. + std::vector failures(const Policy& policy) const; + + std::string summary() const; + + private: + // Streaming least-squares fit of d against p. + struct Fit { + int64_t n = 0; + double mean_p = 0.0; + double mean_d = 0.0; + double cpp = 0.0; + double cpd = 0.0; + double cdd = 0.0; + double sum_dd = 0.0; + double max_abs = 0.0; + + void add(double p, double d); + ErrorStats stats() const; + }; + + Fit engine_; + Fit eager_; + double sum_pp_ = 0.0; + double max_p_ = 0.0; + int64_t nonfinite_mismatches_ = 0; +}; + +} // namespace ptn::testing diff --git a/backends/native/test/samples/test_output_check.cpp b/backends/native/test/samples/test_output_check.cpp new file mode 100644 index 00000000000..73e3d122ee5 --- /dev/null +++ b/backends/native/test/samples/test_output_check.cpp @@ -0,0 +1,257 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. +// All rights reserved. +// +// This source code is licensed under the BSD-style license found in the +// LICENSE file in the root directory of this source tree. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace ptn::testing { +namespace { + +constexpr size_t kCount = 4096; +constexpr float kInf = std::numeric_limits::infinity(); +constexpr float kNaN = std::numeric_limits::quiet_NaN(); + +SampleTensor f32(const std::vector& values) { + SampleTensor t{ + .dtype = kFloat, .sizes = {static_cast(values.size())}}; + t.bytes.resize(values.size() * sizeof(float)); + std::memcpy(t.bytes.data(), values.data(), t.bytes.size()); + return t; +} + +SampleTensor bits16(ScalarType dtype, const std::vector& bits) { + SampleTensor t{.dtype = dtype, .sizes = {static_cast(bits.size())}}; + for (const uint16_t b : bits) { + t.bytes.push_back(b & 0xFF); + t.bytes.push_back(b >> 8); + } + return t; +} + +std::vector normal(double stddev, uint32_t seed) { + std::mt19937 rng(seed); + std::normal_distribution dist(0.0, stddev); + std::vector out(kCount); + for (float& v : out) { + v = static_cast(dist(rng)); + } + return out; +} + +// x * scale + noise + bias, elementwise. +std::vector affine( + const std::vector& x, + double scale, + const std::vector& noise, + double bias = 0.0) { + std::vector out(x.size()); + for (size_t i = 0; i < x.size(); ++i) { + out[i] = static_cast(x[i] * scale + noise[i] + bias); + } + return out; +} + +std::vector failed_rules( + const std::vector& got, + const std::vector& golden, + const std::vector& precise, + const Policy& policy = {}) { + OutputCheck check; + check.add(got, f32(golden), f32(precise)); + std::vector rules; + for (const std::string& failure : check.failures(policy)) { + rules.push_back(failure.substr(0, failure.find(':'))); + } + return rules; +} + +class OutputCheckTest : public ::testing::Test { + protected: + const std::vector precise_ = normal(1.0, 0); + const std::vector golden_ = affine(precise_, 1.0, normal(1e-3, 1)); + const std::vector engine_noise_ = normal(1e-3, 2); +}; + +// cppcheck-suppress-begin syntaxError +TEST(ToFloatTest, DecodesHalfAndBFloat16) { + const std::vector half = {1.5f, -2.0f, std::ldexp(1.0f, -24), -kInf}; + EXPECT_EQ(to_float(bits16(kHalf, {0x3E00, 0xC000, 0x0001, 0xFC00})), half); + const std::vector bf16 = {1.5f, -2.0f, kInf}; + EXPECT_EQ(to_float(bits16(kBFloat16, {0x3FC0, 0xC000, 0x7F80})), bf16); +} + +TEST(ToFloatTest, NarrowsDouble) { + const std::vector values = {1.5, -2.0, 0.1}; + SampleTensor t{.dtype = kDouble, .sizes = {3}}; + t.bytes.resize(values.size() * sizeof(double)); + std::memcpy(t.bytes.data(), values.data(), t.bytes.size()); + const std::vector expected = {1.5f, -2.0f, 0.1f}; + EXPECT_EQ(to_float(t), expected); +} + +TEST(ToFloatTest, RejectsIntegers) { + EXPECT_THROW(to_float(bits16(kShort, {1})), std::invalid_argument); +} + +TEST_F(OutputCheckTest, PassesNoiseLikeEagers) { + EXPECT_EQ( + failed_rules(affine(precise_, 1.0, engine_noise_), golden_, precise_), + std::vector{}); +} + +TEST_F(OutputCheckTest, PassesUlpErrorsAgainstAnExactEager) { + std::vector got = precise_; + for (float& v : got) { + v = std::nextafter(v, kInf); + } + EXPECT_EQ(failed_rules(got, precise_, precise_), std::vector{}); +} + +TEST_F(OutputCheckTest, PassesBiasesSharedWithEager) { + const std::vector golden = + affine(precise_, 1.002, normal(1e-3, 1), 1e-3); + EXPECT_EQ( + failed_rules( + affine(precise_, 1.002, engine_noise_, 1e-3), golden, precise_), + std::vector{}); +} + +TEST_F(OutputCheckTest, FailsNoiseFarAboveEagers) { + const std::vector expected = {"rmse", "max_abs"}; + EXPECT_EQ( + failed_rules(affine(precise_, 1.0, normal(1e-2, 2)), golden_, precise_), + expected); +} + +TEST_F(OutputCheckTest, PassesNoiseFarAboveEagersUnderLooserRatios) { + EXPECT_EQ( + failed_rules( + affine(precise_, 1.0, normal(1e-2, 2)), + golden_, + precise_, + Policy{.rmse_ratio = 20.0, .max_ratio = 20.0}), + std::vector{}); +} + +TEST_F(OutputCheckTest, FailsOneOutlier) { + std::vector precise = precise_; + std::vector golden = golden_; + precise[7] = golden[7] = 0.0f; + std::vector got = affine(precise, 1.0, engine_noise_); + got[7] = 0.1f; + EXPECT_EQ( + failed_rules(got, golden, precise), std::vector{"max_abs"}); +} + +TEST_F(OutputCheckTest, FailsSmallAbsoluteBias) { + EXPECT_EQ( + failed_rules( + affine(precise_, 1.0, engine_noise_, 1e-3), golden_, precise_), + std::vector{"abs_bias"}); +} + +TEST_F(OutputCheckTest, FailsSmallRelativeBias) { + EXPECT_EQ( + failed_rules(affine(precise_, 1.002, engine_noise_), golden_, precise_), + std::vector{"rel_bias"}); +} + +TEST_F(OutputCheckTest, ExcludesNonFiniteMatchingGolden) { + std::vector precise = precise_; + std::vector golden = golden_; + std::vector got = affine(precise_, 1.0, engine_noise_); + golden[0] = got[0] = kNaN; + golden[1] = got[1] = -kInf; + precise[2] = kInf; + got[2] = golden[2]; + + OutputCheck check; + check.add(got, f32(golden), f32(precise)); + + EXPECT_EQ(check.failures(Policy{}), std::vector{}); + EXPECT_EQ(check.engine().count, kCount - 3); +} + +TEST_F(OutputCheckTest, FailsNonFiniteDifferingFromGolden) { + std::vector precise = precise_; + std::vector golden = golden_; + std::vector got = affine(precise_, 1.0, engine_noise_); + golden[0] = kInf; + got[0] = -kInf; + got[1] = kNaN; + golden[2] = kNaN; + precise[3] = kNaN; + + OutputCheck check; + check.add(got, f32(golden), f32(precise)); + + const std::vector expected = { + "nonfinite: 4 values differ from golden where a value is NaN or infinite"}; + EXPECT_EQ(check.failures(Policy{}), expected); +} + +TEST_F(OutputCheckTest, AccumulatesAcrossSamples) { + const std::vector got = affine(precise_, 1.0, engine_noise_, 1e-3); + const size_t half = kCount / 2; + const auto part = [&](const std::vector& v, size_t begin) { + return std::vector(v.begin() + begin, v.begin() + begin + half); + }; + + OutputCheck whole; + whole.add(got, f32(golden_), f32(precise_)); + OutputCheck split; + for (const size_t begin : {size_t{0}, half}) { + split.add( + part(got, begin), + f32(part(golden_, begin)), + f32(part(precise_, begin))); + } + + EXPECT_EQ(split.engine().count, whole.engine().count); + EXPECT_DOUBLE_EQ(split.engine().abs_bias, whole.engine().abs_bias); + EXPECT_DOUBLE_EQ(split.engine().rel_bias_se, whole.engine().rel_bias_se); +} + +TEST(OutputCheckFitTest, MatchesClosedFormOnAnOffsetPrecise) { + // d = got - precise = 1 + 0.5 * precise + e, with e orthogonal to 1 and to + // precise, so the fit is exact and the residual variance is |e|^2 / (n - 2). + const std::vector precise = {1.0f, 2.0f, 3.0f, 4.0f, 5.0f}; + const std::vector got = {2.5f, 4.25f, 5.0f, 7.25f, 8.5f}; + OutputCheck check; + check.add(got, f32(precise), f32(precise)); + + const ErrorStats stats = check.engine(); + const double residual = 0.375 / 3; + EXPECT_EQ(stats.count, 5); + EXPECT_NEAR(stats.rmse, std::sqrt(34.125 / 5), 1e-12); + EXPECT_EQ(stats.max_abs, 3.5); + EXPECT_NEAR(stats.abs_bias, 1.0, 1e-12); + EXPECT_NEAR(stats.rel_bias, 0.5, 1e-12); + EXPECT_NEAR( + stats.abs_bias_se, std::sqrt(residual * (1.0 / 5 + 9.0 / 10)), 1e-12); + EXPECT_NEAR(stats.rel_bias_se, std::sqrt(residual / 10), 1e-12); +} + +TEST(OutputCheckSizeTest, RejectsMismatchedCounts) { + OutputCheck check; + EXPECT_THROW( + check.add(std::vector{1.0f}, f32({1.0f, 2.0f}), f32({1.0f})), + std::invalid_argument); +} +// cppcheck-suppress-end syntaxError + +} // namespace +} // namespace ptn::testing From 0acda5755a7dcffe1ef4cc8e643fbb60f4009cd3 Mon Sep 17 00:00:00 2001 From: Digant Desai Date: Tue, 3 Nov 2026 06:24:25 -0800 Subject: [PATCH 114/128] [executorch][native] Run engines on bundled op samples with gtest Pull Request resolved: https://github.com/pytorch/executorch/pull/24360 The bundled samples need a harness that runs on any engine. `sample_test_main` registers one gtest per package and method and judges each output with `OutputCheck`; unsupported ops, dtypes and shapes fail. `ggml_sample_test{,_cuda,_hip}` and `vulkan_sample_test` run it on `op_samples`. With CMake, `native_ggml_sample_test` (CPU) and `native_vulkan_sample_test` depend on `native_op_samples` and run it on its output. The sample tests are manual for now, since gaps fail: `ci.skip_test` in Buck and `DISABLED` in CTest. The README says how to run them and what the generator needs. `vulkan_sample_test` runs on SwiftShader like `vulkan_engine_test`; preloading a driver's `libvulkan.so.1` runs it on that driver. The ggml CUDA targets preload the CUDA driver stub through their env, so `test_ggml_ops.py` no longer puts the runner's directory on `LD_LIBRARY_PATH`. Authored with Claude Code. Differential Revision: [D128254880](https://our.internmc.facebook.com/intern/diff/D128254880/) **NOTE FOR REVIEWERS**: This PR has internal Meta-specific changes or comments, please review them on [Phabricator](https://our.internmc.facebook.com/intern/diff/D128254880/)! ghstack-source-id: 463657748 --- backends/native/README.md | 7 +- backends/native/runtime/deserialize/Samples.h | 2 + backends/native/test/CMakeLists.txt | 59 ++++ backends/native/test/runtime/ggml/BUCK | 34 ++- .../native/test/runtime/ggml/test_ggml_ops.py | 6 - .../native/test/runtime/vulkan/targets.bzl | 17 ++ backends/native/test/samples/BUCK | 22 ++ .../native/test/samples/sample_test_main.cpp | 251 ++++++++++++++++++ 8 files changed, 383 insertions(+), 15 deletions(-) create mode 100644 backends/native/test/samples/sample_test_main.cpp diff --git a/backends/native/README.md b/backends/native/README.md index 9ce1b69d277..8c51b12efac 100644 --- a/backends/native/README.md +++ b/backends/native/README.md @@ -206,7 +206,12 @@ and API. `native_cpu_engine_test` covers the CPU engine when `EXECUTORCH_BUILD_NATIVE_CPU` is enabled. `native_vulkan_passes_test` covers the Vulkan graph rewrites without requiring a GPU. When either runner below is enabled, `native_runner_utils_test` covers their shared runner utilities. Run these -with `ctest --test-dir cmake-out -R native_`. +with `ctest --test-dir cmake-out -R native_` after `cmake --build cmake-out`. + +The engines' sample tests judge them on the bundled op samples. Building them runs +the sample generator, which needs `torch` and `torchao`. Those with failing cases +are disabled in CTest; run their binaries directly with +`--packages=/backends/native/test/op_samples`. The `Test Native Engines` CI workflow has separate Linux jobs for the shared runtime, CPU, GGML, and Vulkan, with each test suite run once. To reproduce with diff --git a/backends/native/runtime/deserialize/Samples.h b/backends/native/runtime/deserialize/Samples.h index bbbac2035c6..e2159616d09 100644 --- a/backends/native/runtime/deserialize/Samples.h +++ b/backends/native/runtime/deserialize/Samples.h @@ -23,6 +23,8 @@ struct SampleTensor { ScalarType dtype = kFloat; std::vector sizes; std::vector bytes; + + bool operator==(const SampleTensor&) const = default; }; struct Tolerance { diff --git a/backends/native/test/CMakeLists.txt b/backends/native/test/CMakeLists.txt index f6a72ffde51..719edc54578 100644 --- a/backends/native/test/CMakeLists.txt +++ b/backends/native/test/CMakeLists.txt @@ -65,6 +65,31 @@ add_custom_command( ) add_custom_target(native_op_samples DEPENDS ${_native_op_samples}.stamp) +if(EXECUTORCH_BUILD_NATIVE_GGML OR EXECUTORCH_BUILD_NATIVE_VULKAN) + if(NOT TARGET gflags AND NOT TARGET gflags::gflags) + find_package(gflags REQUIRED) + endif() + if(TARGET gflags::gflags) + set(_native_test_gflags gflags::gflags) + else() + set(_native_test_gflags gflags) + endif() + + # A gtest main judging the engine whose host a test links on the bundled + # samples of the packages it names; engine tests set its flags. + add_library(native_sample_test_main OBJECT samples/sample_test_main.cpp) + target_link_libraries( + native_sample_test_main + PUBLIC native_output_check native_runtime native_package native_engine + ${_native_test_gflags} GTest::gtest + ) + target_compile_options( + native_sample_test_main + PRIVATE $<$:/EHsc> + $<$>:-fexceptions> + ) +endif() + if(EXECUTORCH_BUILD_NATIVE_VULKAN) add_executable( native_vulkan_passes_test @@ -86,6 +111,23 @@ if(EXECUTORCH_BUILD_NATIVE_VULKAN) PRIVATE $<$:/EHsc> $<$>:-fexceptions> ) + + add_executable(native_vulkan_sample_test runtime/vulkan/vulkan_ptn_runner.cpp) + target_link_libraries( + native_vulkan_sample_test PRIVATE native_sample_test_main + native_vulkan_engine + ) + add_dependencies(native_vulkan_sample_test native_op_samples) + add_test(NAME native_vulkan_sample_test + COMMAND native_vulkan_sample_test --packages=${_native_op_samples} + ) + # TODO: Enable once every case passes. + set_tests_properties(native_vulkan_sample_test PROPERTIES DISABLED TRUE) + target_compile_options( + native_vulkan_sample_test + PRIVATE $<$:/EHsc> + $<$>:-fexceptions> + ) endif() if(EXECUTORCH_BUILD_NATIVE_GGML) @@ -101,6 +143,23 @@ if(EXECUTORCH_BUILD_NATIVE_GGML) PRIVATE $<$:/EHsc> $<$>:-fexceptions> ) + + add_executable(native_ggml_sample_test runtime/ggml/ggml_ptn_runner.cpp) + target_link_libraries( + native_ggml_sample_test PRIVATE native_sample_test_main native_ggml_engine + ) + add_dependencies(native_ggml_sample_test native_op_samples) + add_test(NAME native_ggml_sample_test + COMMAND native_ggml_sample_test --packages=${_native_op_samples} + --device=cpu + ) + # TODO: Enable once every case passes. + set_tests_properties(native_ggml_sample_test PROPERTIES DISABLED TRUE) + target_compile_options( + native_ggml_sample_test + PRIVATE $<$:/EHsc> + $<$>:-fexceptions> + ) endif() if(TARGET native_runner_utils) diff --git a/backends/native/test/runtime/ggml/BUCK b/backends/native/test/runtime/ggml/BUCK index 05de9fa2b72..71d998be09e 100644 --- a/backends/native/test/runtime/ggml/BUCK +++ b/backends/native/test/runtime/ggml/BUCK @@ -1,5 +1,6 @@ load("@fbcode_macros//build_defs:build_file_migration.bzl", "fbcode_target") load("@fbsource//third-party/llama.cpp:ggml_ci.bzl", "ggml_ci_labels") +load("@fbsource//tools/target_determinator/macros:ci.bzl", "ci") load("@fbsource//xplat/executorch/backends/native/runtime/engine:targets.bzl", "CPU_COMPAT", "CUDA_COMPAT", "HIP_COMPAT") load("@fbcode_macros//build_defs/lib:re_test_utils.bzl", "re_test_utils") load("@fbsource//xplat/executorch/build:runtime_wrapper.bzl", "runtime") @@ -23,7 +24,7 @@ fbcode_target( ) # The op suite runs once per ggml backend set; only the engine variant, the -# runner's --device, link deps and resources, the test host, and the CI +# runner's --device, link deps and test env, the test host, and the CI # configuration differ. _VARIANTS = [ {"suffix": "", "engine": "ggml_engine_cpu", "compat": CPU_COMPAT, "device": "cpu"}, @@ -40,10 +41,9 @@ _VARIANTS = [ # beside it, and its VMM pool calls the CUDA driver API. "deps": ["fbsource//third-party/llama.cpp:ggml_base[prefer-shared]"], "external_deps": [("cuda", None, "cuda_driver_api-lazy")], - # Buck does not put the CUDA driver stub's library on the runner's - # loader path, so it ships beside the runner. - "resources": { - "//tools/lazify:lazy_stub_binder[shared]": "libtools_lazify_lazy_stub_binder.so", + "env": { + # Buck does not put the CUDA driver stub's library on the loader path. + # @oss-disable[end= ]: "LD_PRELOAD": "$(location //tools/lazify:lazy_stub_binder[shared])", }, }, { @@ -91,16 +91,34 @@ _VARIANTS = [ for v in _VARIANTS ] +[ + fbcode_target( + _kind = runtime.cxx_test, + name = "ggml_sample_test" + v["suffix"], + args = [ + "--packages=$(location //executorch/backends/native/test/samples:op_samples)", + "--device=" + v["device"], + ], + env = v.get("env", {}), + remote_execution = v.get("remote_execution"), + # TODO: run in CI once they pass. + labels = ci.labels(ci.map(ci.skip_test())), + test_main = "//executorch/backends/native/test/samples:sample_test_main", + deps = [":ggml_engine_host" + v["suffix"]], + **v.get("compat", {}) + ) + for v in _VARIANTS +] + [ fbcode_target( _kind = runtime.python_test, name = "test_ggml_ops" + v["suffix"], srcs = ["test_ggml_ops.py"], - env = {"GGML_PTN_DEVICE": v["device"]}, + env = {"GGML_PTN_DEVICE": v["device"]} | v.get("env", {}), labels = v.get("labels", []), remote_execution = v.get("remote_execution"), - resources = {":ggml_ptn_runner" + v["suffix"]: "ggml_ptn_runner"} | - v.get("resources", {}), + resources = {":ggml_ptn_runner" + v["suffix"]: "ggml_ptn_runner"}, deps = [ "//caffe2:torch", "//executorch/exir/native:native", diff --git a/backends/native/test/runtime/ggml/test_ggml_ops.py b/backends/native/test/runtime/ggml/test_ggml_ops.py index 2a7169ffecd..1635f5e7082 100644 --- a/backends/native/test/runtime/ggml/test_ggml_ops.py +++ b/backends/native/test/runtime/ggml/test_ggml_ops.py @@ -334,18 +334,12 @@ def _run_ggml( command.append(f"--input_sizes={';'.join(map(_shape_text, steps))}") if graph_ops is not None: command.append("--print_graph_ops") - env = dict(os.environ) with as_file(files(__package__) / "ggml_ptn_runner") as runner: - # Shared libraries the runner needs may ship beside it. - env["LD_LIBRARY_PATH"] = ( - f"{Path(runner).parent}:{env.get('LD_LIBRARY_PATH', '')}" - ) result = subprocess.run( [str(runner), *command], capture_output=True, text=True, timeout=300, - env=env, ) if result.returncode != 0: raise AssertionError(f"ggml_ptn_runner failed:\n{result.stderr}") diff --git a/backends/native/test/runtime/vulkan/targets.bzl b/backends/native/test/runtime/vulkan/targets.bzl index 1e707ee89c0..33f56115527 100644 --- a/backends/native/test/runtime/vulkan/targets.bzl +++ b/backends/native/test/runtime/vulkan/targets.bzl @@ -1,3 +1,4 @@ +load("@fbsource//tools/target_determinator/macros:ci.bzl", "ci") load("@fbsource//xplat/executorch/build:runtime_wrapper.bzl", "is_xplat", "runtime") def define_common_targets(): @@ -60,6 +61,22 @@ def define_common_targets(): ], ) + # TODO: run in CI once it passes on SwiftShader. + runtime.cxx_test( + name = "vulkan_sample_test", + args = ["--packages=$(location //executorch/backends/native/test/samples:op_samples)"], + env = { + "ASAN_OPTIONS": "detect_leaks=0", + "LD_LIBRARY_PATH": "$(location :vulkan_test_driver)", + }, + labels = ci.labels(ci.map(ci.skip_test())), + test_main = "//executorch/backends/native/test/samples:sample_test_main", + deps = [ + ":vulkan_engine_host", + "fbsource//third-party/swiftshader/lib/linux-x64:libvk_swiftshader_fbcode", + ], + ) + runtime.cxx_test( name = "vulkan_engine_test", srcs = ["test_vulkan_engine.cpp"], diff --git a/backends/native/test/samples/BUCK b/backends/native/test/samples/BUCK index a59019b033c..122272f1d3e 100644 --- a/backends/native/test/samples/BUCK +++ b/backends/native/test/samples/BUCK @@ -66,3 +66,25 @@ fbcode_target( srcs = ["test_output_check.cpp"], deps = [":output_check"], ) + +# A gtest main judging the engine whose host library a test links on the +# bundled samples of the packages it names; engine tests set its flags. +fbcode_target( + _kind = runtime.cxx_library, + name = "sample_test_main", + srcs = ["sample_test_main.cpp"], + link_whole = True, + undefined_symbols = True, + visibility = ["//executorch/backends/native/test/..."], + exported_deps = ["//executorch/backends/native/test/runtime:engine_host"], + deps = [ + ":output_check", + "//executorch/backends/native/runtime:runtime", + "//executorch/backends/native/runtime/deserialize:package", + "//executorch/backends/native/runtime/deserialize:samples", + ], + external_deps = [ + "gflags", + "gtest", + ], +) diff --git a/backends/native/test/samples/sample_test_main.cpp b/backends/native/test/samples/sample_test_main.cpp new file mode 100644 index 00000000000..584676a49f6 --- /dev/null +++ b/backends/native/test/samples/sample_test_main.cpp @@ -0,0 +1,251 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. +// All rights reserved. +// +// This source code is licensed under the BSD-style license found in the +// LICENSE file in the root directory of this source tree. + +// A gtest main that runs the bundled samples of .ptn packages on the engine the +// binary links (see PtnRunner.h): one test per package and method, grouping +// samples by their first call. Each sample runs from a fresh context, and each +// method output is judged over all its samples by OutputCheck. +// +// Usage: +// --packages=a.ptn,dir [--rmse_ratio --max_ratio --rtol --sigmas] +// [engine host flags] [gtest flags] + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +DEFINE_string( + packages, + "", + "Comma-separated .ptn files or directories of them."); +DEFINE_double( + rmse_ratio, + ptn::testing::Policy{}.rmse_ratio, + "Allowed engine RMSE and biases, in multiples of eager's."); +DEFINE_double( + max_ratio, + ptn::testing::Policy{}.max_ratio, + "Allowed engine max abs error, in multiples of eager's."); +DEFINE_double( + rtol, + ptn::testing::Policy{}.rtol, + "Error allowed on top, relative to the precise outputs' magnitude."); +DEFINE_double( + sigmas, + ptn::testing::Policy{}.sigmas, + "Standard errors the fitted biases may deviate by."); + +namespace ptn::testing { +namespace { + +struct Bundle { + std::shared_ptr package; + std::shared_ptr program; + SampleSet golden; + SampleSet precise; +}; + +// Created on first use, so listing tests touches no device. +EngineHost& host() { + static const std::unique_ptr instance = [] { + std::unique_ptr made = make_engine_host(); + std::printf( + "engine %s on %s\n", made->name().c_str(), made->device_name().c_str()); + return made; + }(); + return *instance; +} + +// Keyed by method and output index. +using Checks = std::map, OutputCheck>; + +class SampleTest : public ::testing::Test { + public: + SampleTest(std::shared_ptr bundle, std::string method) + : bundle_(std::move(bundle)), method_(std::move(method)) {} + + void TestBody() override { + const std::vector& golden = bundle_->golden.samples; + const std::vector& precise = bundle_->precise.samples; + ASSERT_EQ(golden.size(), precise.size()); + Checks checks; + for (size_t s = 0; s < golden.size(); ++s) { + if (golden[s].calls.front().method != method_) { + continue; + } + SCOPED_TRACE("sample " + std::to_string(s)); + try { + run(golden[s], precise[s], checks); + } catch (const std::exception& e) { + FAIL() << e.what(); + } + if (HasFatalFailure()) { + return; + } + } + const Policy policy{ + .rmse_ratio = FLAGS_rmse_ratio, + .max_ratio = FLAGS_max_ratio, + .rtol = FLAGS_rtol, + .sigmas = FLAGS_sigmas}; + for (const auto& [key, check] : checks) { + const std::string name = key.first + " out " + std::to_string(key.second); + std::printf("%s: %s\n", name.c_str(), check.summary().c_str()); + for (const std::string& failure : check.failures(policy)) { + ADD_FAILURE() << name << ": " << failure; + } + } + } + + private: + void run(const Sample& golden, const Sample& precise, Checks& checks) { + ASSERT_EQ(golden.calls.size(), precise.calls.size()); + const std::unique_ptr context = + host().create_context(bundle_->program, bundle_->package); + std::map> executables; + for (size_t c = 0; c < golden.calls.size(); ++c) { + const SampleCall& call = golden.calls[c]; + ASSERT_EQ(call.method, precise.calls[c].method); + ASSERT_TRUE(call.inputs == precise.calls[c].inputs) + << call.method << " has golden and precise inputs that differ"; + ASSERT_TRUE(call.outputs && precise.calls[c].outputs) + << call.method << " has no recorded outputs"; + const std::vector& want = *call.outputs; + const std::vector& ref = *precise.calls[c].outputs; + std::unique_ptr& executable = executables[call.method]; + if (!executable) { + executable = context->compile(call.method); + } + ASSERT_EQ(executable->num_inputs(), call.inputs.size()); + ASSERT_EQ(executable->num_outputs(), want.size()); + ASSERT_EQ(ref.size(), want.size()); + + for (size_t i = 0; i < call.inputs.size(); ++i) { + const SampleTensor& in = call.inputs[i]; + ASSERT_STREQ( + scalar_type_name(executable->input_dtype(i)), + scalar_type_name(in.dtype)) + << "input " << i; + if (executable->input_sizes(i) != in.sizes) { + executable->resize_input(i, in.sizes); + } + executable->set_input( + i, + in.bytes.data(), + in.bytes.size() / element_size(in.dtype), + in.dtype); + } + executable->execute(); + for (size_t o = 0; o < want.size(); ++o) { + ASSERT_EQ(executable->output_sizes(o), want[o].sizes) << "output " << o; + ASSERT_EQ(ref[o].sizes, want[o].sizes) << "precise output " << o; + ASSERT_STREQ( + scalar_type_name(executable->output_dtype(o)), + scalar_type_name(want[o].dtype)) + << "output " << o; + std::vector got( + want[o].bytes.size() / element_size(want[o].dtype)); + executable->get_output(o, got.data(), got.size(), kFloat); + checks[{call.method, o}].add(got, want[o], ref[o]); + } + } + } + + std::shared_ptr bundle_; + std::string method_; +}; + +std::vector package_paths() { + std::vector paths; + std::stringstream list(FLAGS_packages); + for (std::string entry; std::getline(list, entry, ',');) { + if (entry.empty()) { + continue; + } + if (!std::filesystem::is_directory(entry)) { + paths.emplace_back(entry); + continue; + } + std::vector found; + for (const auto& file : std::filesystem::directory_iterator(entry)) { + if (file.path().extension() == ".ptn") { + found.push_back(file.path()); + } + } + std::ranges::sort(found); + paths.insert(paths.end(), found.begin(), found.end()); + } + return paths; +} + +void register_package(const std::filesystem::path& path) { + auto bundle = std::make_shared(); + bundle->package = + std::make_shared(Package::load(path.string())); + const ByteSpan program = bundle->package->program_bytes(); + bundle->program = std::make_shared( + Program::load(program.data(), program.size())); + const ZipReader zip = ZipReader::open(path.string()); + bundle->golden = read_sample_set(zip, "golden"); + bundle->precise = read_sample_set(zip, "precise"); + + std::set methods; + for (const Sample& sample : bundle->golden.samples) { + methods.insert(sample.calls.front().method); + } + const std::string suite = path.stem().string(); + for (const std::string& method : methods) { + ::testing::RegisterTest( + suite.c_str(), + method.c_str(), + nullptr, + nullptr, + __FILE__, + __LINE__, + [bundle, method]() { return new SampleTest(bundle, method); }); + } +} + +} // namespace +} // namespace ptn::testing + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + gflags::ParseCommandLineFlags(&argc, &argv, true); + try { + const std::vector paths = + ptn::testing::package_paths(); + if (paths.empty()) { + throw std::invalid_argument("--packages names no .ptn"); + } + for (const std::filesystem::path& path : paths) { + ptn::testing::register_package(path); + } + } catch (const std::exception& e) { + std::fprintf(stderr, "error: %s\n", e.what()); + return 1; + } + return RUN_ALL_TESTS(); +} From cba25b180a6c0f063e33b144f7a826386862c27c Mon Sep 17 00:00:00 2001 From: Digant Desai Date: Tue, 3 Nov 2026 06:24:25 -0800 Subject: [PATCH 115/128] [executorch][native-ggml] Reject weight-only quantized linears unless activation quant is allowed Pull Request resolved: https://github.com/pytorch/executorch/pull/24361 ggml quantizes the activations of its quantized matmuls, so weight-only int4 and GGUF linears silently ran with numerics the PTN doesn't describe. Compile now rejects them unless `GgmlEngineOptions::allow_activation_quant_for_weight_only` is set. `ggml_ptn_runner` and `run_hf_llm_ggml` expose it as `--allow_activation_quant_for_weight_only`, which GGUF LLMs on ggml now need. Authored with Claude Code. @bypass-github-export-checks @bypass-github-pytorch-ci-checks @bypass-github-executorch-ci-checks ghstack-source-id: 463657742 @exported-using-ghexport Differential Revision: [D128254887](https://our.internmc.facebook.com/intern/diff/D128254887/) --- backends/native/README.md | 3 + .../hf_llm/runtime/run_hf_llm_ggml.cpp | 7 ++ .../native/runtime/engine/ggml/GgmlEngine.h | 5 ++ .../runtime/engine/ggml/GgmlExecutor.cpp | 20 ++++++ backends/native/test/e2e/test_cross_engine.py | 37 +++++++---- .../test/runtime/ggml/ggml_ptn_runner.cpp | 6 ++ .../test/runtime/ggml/test_ggml_engine.cpp | 64 ++++++++++++++----- .../native/test/runtime/ggml/test_ggml_ops.py | 38 +++++++++-- 8 files changed, 150 insertions(+), 30 deletions(-) diff --git a/backends/native/README.md b/backends/native/README.md index 8c51b12efac..8a2d76ce20a 100644 --- a/backends/native/README.md +++ b/backends/native/README.md @@ -339,6 +339,9 @@ install/bin/run_hf_llm_ggml --device cpu --cpu_threads 4 \ The GGML runner accepts `--device cpu|gpu`, `--device_index`, `--cpu_threads`, and `--max_cached_graphs` (default 1, 0 rebuilds each call). For GPU execution, enable the desired `GGML_*` backend when configuring ExecuTorch, as above. +GGML quantizes the activations of its quantized matmuls, so it rejects +weight-only quantized linears, including GGUF k-quants, unless +`--allow_activation_quant_for_weight_only` accepts that loss of precision. With `EXECUTORCH_BUILD_TESTS=ON`, `native_llm_runner_utils_test` exercises the LLM generation utilities. diff --git a/backends/native/examples/hf_llm/runtime/run_hf_llm_ggml.cpp b/backends/native/examples/hf_llm/runtime/run_hf_llm_ggml.cpp index b9c1c73233b..9115b9e0818 100644 --- a/backends/native/examples/hf_llm/runtime/run_hf_llm_ggml.cpp +++ b/backends/native/examples/hf_llm/runtime/run_hf_llm_ggml.cpp @@ -23,6 +23,11 @@ DEFINE_int32( max_cached_graphs, 1, "Graphs kept per executable by input shapes; 0 rebuilds every call."); +DEFINE_bool( + allow_activation_quant_for_weight_only, + false, + "Run weight-only quantized linears, such as GGUF k-quants, with ggml's " + "quantized activations."); int main(int argc, char** argv) { gflags::SetUsageMessage( @@ -41,6 +46,8 @@ int main(int argc, char** argv) { throw std::invalid_argument("--max_cached_graphs must be non-negative"); } options.max_cached_graphs = static_cast(FLAGS_max_cached_graphs); + options.allow_activation_quant_for_weight_only = + FLAGS_allow_activation_quant_for_weight_only; return ptn::GgmlEngineHost::create(options); }); } diff --git a/backends/native/runtime/engine/ggml/GgmlEngine.h b/backends/native/runtime/engine/ggml/GgmlEngine.h index 8fad5e69e17..61b64add99e 100644 --- a/backends/native/runtime/engine/ggml/GgmlEngine.h +++ b/backends/native/runtime/engine/ggml/GgmlEngine.h @@ -40,6 +40,11 @@ struct GgmlEngineOptions { // Prints each newly built graph's node count per ggml op to stderr, which // shows whether a lowering rewrite fired. bool print_graph_ops = false; + // ggml backends quantize the activations of a quantized matmul themselves, + // so a linear with quantized weights, int4 or GGUF, whose input is not a + // dequantized activation is rejected unless the caller accepts that loss of + // precision. + bool allow_activation_quant_for_weight_only = false; }; class GgmlEngineContext; diff --git a/backends/native/runtime/engine/ggml/GgmlExecutor.cpp b/backends/native/runtime/engine/ggml/GgmlExecutor.cpp index 66e5c2cf02a..b6537bd861a 100644 --- a/backends/native/runtime/engine/ggml/GgmlExecutor.cpp +++ b/backends/native/runtime/engine/ggml/GgmlExecutor.cpp @@ -542,7 +542,27 @@ class GgmlExecutable final : public EngineExecutable { "' may only feed linear or embedding weights, not '" + node.target + "'"); } + if (op == "aten.linear.default" && + !options_.allow_activation_quant_for_weight_only && + !has_dequantized_input(node)) { + throw std::runtime_error( + "ggml: weight-only quantized linear '" + node.name + + "' needs allow_activation_quant_for_weight_only, since ggml " + "quantizes its activations"); + } + } + } + + bool has_dequantized_input(const Node& linear) const { + const NamedArgument* input = find_arg(linear, "input", 0); + if (input == nullptr || input->arg.kind() != ArgKind::Tensor) { + return false; } + const NodeId producer = + method_.graph.value(input->arg.as_tensor().id).producer_id; + return valid(producer) && + op_key(method_.graph.node(producer).target) == + "torchao.dequantize_affine.default"; } // Converters read some dtypes through typed pointers, so the package's bytes diff --git a/backends/native/test/e2e/test_cross_engine.py b/backends/native/test/e2e/test_cross_engine.py index c6748064850..940f555a4bc 100644 --- a/backends/native/test/e2e/test_cross_engine.py +++ b/backends/native/test/e2e/test_cross_engine.py @@ -35,7 +35,14 @@ _MAX_SEQ = 16 -def _run(runner: str, package: Path, steps, outdir: Path, resize: bool = False): +def _run( + runner: str, + package: Path, + steps, + outdir: Path, + resize: bool = False, + flags: tuple[str, ...] = (), +): """Runs each step's inputs in order on one executable; returns each step's single float32 output.""" input_steps, output_steps, shapes = [], [], [] @@ -55,6 +62,7 @@ def _run(runner: str, package: Path, steps, outdir: Path, resize: bool = False): ] if resize: command.append(f"--input_sizes={';'.join(shapes)}") + command.extend(flags) resources = files(__package__) with as_file(resources / runner) as binary, as_file( resources / "vulkan_driver" @@ -390,11 +398,13 @@ def test_inplace_relu_of_dynamic_view(self): seq = Dim("seq", min=2, max=12) self._check_dynamic(_ViewRelu(), steps, ({1: seq},)) - def _check_engines(self, package, steps, expected, outdir, atol_fraction, engines): + def _check_engines( + self, package, steps, expected, outdir, atol_fraction, engines, flags=() + ): tokens = {} for runner in engines: with self.subTest(engine=runner): - outputs = _run(runner, package, steps, outdir, resize=True) + outputs = _run(runner, package, steps, outdir, resize=True, flags=flags) self.assertEqual(len(outputs), len(expected)) for step, (actual, reference) in enumerate(zip(outputs, expected)): self.assertEqual(actual.shape, reference.shape) @@ -433,6 +443,7 @@ def _check_llama( atol_fraction: float, engines=_ENGINES, max_seq: int = _MAX_SEQ, + flags: tuple[str, ...] = (), ): prompt = torch.randint(0, model.config.vocab_size, (1, 7)) eager = LastTokenLM(copy.deepcopy(model), max_seq, kv_cache_dtype).eval() @@ -457,7 +468,7 @@ def _check_llama( package = outdir / "tiny_llama.ptn" to_native(ep).save(str(package)) tokens = self._check_engines( - package, steps, expected, outdir, atol_fraction, engines + package, steps, expected, outdir, atol_fraction, engines, flags ) greedy = [e[0, -1].argmax().item() for e in expected] for runner, chosen in tokens.items(): @@ -472,16 +483,20 @@ def test_tiny_llama_gguf(self): model = _tiny_llama() pairs = _gguf_weights(model) load_gguf(model, "tiny.gguf", "llama", lambda _: iter(pairs)) - # ggml requantizes activations to Q8_K for its K-quant matmuls, while - # native-vk decodes the K-quant weights exactly to fp32. Re-seeding - # gives both engines the same prompt. - for runner, atol_fraction in ( - ("ggml_ptn_runner", 5e-2), - ("vulkan_ptn_runner", 1e-3), + # ggml requantizes activations to Q8_K for its K-quant matmuls, once + # allowed to, while native-vk decodes the K-quant weights exactly to + # fp32. Re-seeding gives both engines the same prompt. + for runner, atol_fraction, flags in ( + ("ggml_ptn_runner", 5e-2, ("--allow_activation_quant_for_weight_only",)), + ("vulkan_ptn_runner", 1e-3, ()), ): torch.manual_seed(0) self._check_llama( - copy.deepcopy(model), torch.float32, atol_fraction, engines=(runner,) + copy.deepcopy(model), + torch.float32, + atol_fraction, + engines=(runner,), + flags=flags, ) def test_tiny_llama_fp16_kv(self): diff --git a/backends/native/test/runtime/ggml/ggml_ptn_runner.cpp b/backends/native/test/runtime/ggml/ggml_ptn_runner.cpp index 85336504930..ad600382f04 100644 --- a/backends/native/test/runtime/ggml/ggml_ptn_runner.cpp +++ b/backends/native/test/runtime/ggml/ggml_ptn_runner.cpp @@ -26,6 +26,10 @@ DEFINE_bool( print_graph_ops, false, "Print each built graph's node count per ggml op to stderr."); +DEFINE_bool( + allow_activation_quant_for_weight_only, + false, + "Run weight-only quantized linears with ggml's quantized activations."); namespace ptn::testing { @@ -44,6 +48,8 @@ std::unique_ptr make_engine_host() { } options.max_cached_graphs = static_cast(FLAGS_max_cached_graphs); options.print_graph_ops = FLAGS_print_graph_ops; + options.allow_activation_quant_for_weight_only = + FLAGS_allow_activation_quant_for_weight_only; return GgmlEngineHost::create(options); } diff --git a/backends/native/test/runtime/ggml/test_ggml_engine.cpp b/backends/native/test/runtime/ggml/test_ggml_engine.cpp index a7498fc9a07..e78f6ec8cee 100644 --- a/backends/native/test/runtime/ggml/test_ggml_engine.cpp +++ b/backends/native/test/runtime/ggml/test_ggml_engine.cpp @@ -81,6 +81,11 @@ flatbuffers::Offset create_none_arg( constexpr size_t kQ4KBlockBytes = 144; +// The safetensors header of the affine linear program's constants. +constexpr const char* kQ4_0SafeTensorsHeader = + R"({"weight_scale":{"dtype":"F16","shape":[1,8],"data_offsets":[0,16]},)" + R"("weight":{"dtype":"U8","shape":[128],"data_offsets":[16,144]}})"; + // A [1, 256] x [256] linear with gguf:q4_k weights, or with symmetric int4 // AffineGroup weights whose fp16 scales `weight_scale` cover groups of 32. // `cache` is an unused mutable buffer. @@ -232,6 +237,10 @@ std::vector make_linear_program( builder.GetBufferPointer() + builder.GetSize()}; } +// For tests of quantized linears whose activation numerics aren't under test. +constexpr GgmlEngineOptions kAllowActivationQuant{ + .allow_activation_quant_for_weight_only = true}; + std::unique_ptr make_context( GgmlEngineHost& host, const std::vector& program_bytes) { @@ -473,7 +482,7 @@ std::vector make_cache_update_program( // cppcheck-suppress syntaxError TEST(GgmlEngineTest, ExecutesPackedQ4KLinear) { - auto host = GgmlEngineHost::create(); + auto host = GgmlEngineHost::create(kAllowActivationQuant); auto context = make_context(*host, make_linear_program()); auto executable = context->compile("forward"); @@ -491,8 +500,9 @@ TEST(GgmlEngineTest, ExecutesPackedQ4KLinear) { TEST(GgmlEngineTest, RepeatedExecutionsReadFreshInputs) { for (const size_t max_cached_graphs : {size_t{0}, size_t{1}}) { SCOPED_TRACE(max_cached_graphs); - auto host = GgmlEngineHost::create( - GgmlEngineOptions{.max_cached_graphs = max_cached_graphs}); + auto host = GgmlEngineHost::create(GgmlEngineOptions{ + .max_cached_graphs = max_cached_graphs, + .allow_activation_quant_for_weight_only = true}); auto context = make_context(*host, make_linear_program()); auto executable = context->compile("forward"); for (const float value : {1.0f, 2.0f, -1.0f}) { @@ -580,7 +590,7 @@ TEST(GgmlEngineTest, RejectsUnsupportedOpAtCompile) { } TEST(GgmlEngineTest, RejectsSecondExecutableBindingMutableBuffer) { - auto host = GgmlEngineHost::create(); + auto host = GgmlEngineHost::create(kAllowActivationQuant); auto context = make_context( *host, make_linear_program( @@ -590,12 +600,10 @@ TEST(GgmlEngineTest, RejectsSecondExecutableBindingMutableBuffer) { } void expect_compile_error( - const std::vector& program, + GgmlEngineContext& context, const std::string& message) { - auto host = GgmlEngineHost::create(); - auto context = make_context(*host, program); try { - context->compile("forward"); + context.compile("forward"); FAIL() << "compile accepted the program"; } catch (const std::runtime_error& error) { EXPECT_NE(std::string(error.what()).find(message), std::string::npos) @@ -603,6 +611,32 @@ void expect_compile_error( } } +void expect_compile_error( + const std::vector& program, + const std::string& message) { + auto host = GgmlEngineHost::create(); + expect_compile_error(*make_context(*host, program), message); +} + +TEST(GgmlEngineTest, RejectsWeightOnlyQuantizedLinearsByDefault) { + expect_compile_error( + make_linear_program(), "allow_activation_quant_for_weight_only"); + const std::vector program_bytes = make_linear_program( + "torch.ops.aten.linear.default", /*with_cache=*/false, /*affine=*/true); + auto host = GgmlEngineHost::create(); + auto context = host->create_ggml_context( + std::make_shared( + Program::load(program_bytes.data(), program_bytes.size())), + std::make_shared( + Package::load(OwnedBytes::from_vector(testing::make_zip({ + {kProgramEntry, program_bytes}, + {kSafeTensorsEntry, + testing::make_safetensors( + kQ4_0SafeTensorsHeader, /*scales + weights=*/16 + 128)}, + }))))); + expect_compile_error(*context, "allow_activation_quant_for_weight_only"); +} + TEST(GgmlEngineTest, RejectsIntegerGelu) { TestProgram program; program.input("x", fbs::ScalarType::LONG, {2, 3}); @@ -773,7 +807,7 @@ TEST(GgmlEngineTest, WriteToCachedMaskReachesLaterAttention) { } TEST(GgmlEngineTest, ReleasesMutableBufferWithItsExecutable) { - auto host = GgmlEngineHost::create(); + auto host = GgmlEngineHost::create(kAllowActivationQuant); auto context = make_context( *host, make_linear_program( @@ -1067,7 +1101,7 @@ TEST(GgmlEngineTest, Q4KLinearOfAHalfInput) { {{"input", std::string("x")}, {"weight", std::string("weight")}}, fbs::ScalarType::HALF, {1, 1}); - auto host = GgmlEngineHost::create(); + auto host = GgmlEngineHost::create(kAllowActivationQuant); auto context = make_context(*host, program.finish("y")); auto executable = context->compile("forward"); // The IEEE half bit pattern of 1.0. @@ -1399,7 +1433,9 @@ TEST(GgmlEngineTest, RejectsPackedWeightWithPartialBlocks) { {{"input", std::string("x")}, {"weight", std::string("weight")}}, fbs::ScalarType::FLOAT, {1, 1}); - expect_compile_error(program.finish("y"), "divisible by 256"); + auto host = GgmlEngineHost::create(kAllowActivationQuant); + expect_compile_error( + *make_context(*host, program.finish("y")), "divisible by 256"); } TEST(GgmlEngineTest, RejectsUnsupportedOpOnUnquantizedInput) { @@ -1478,9 +1514,7 @@ TEST(GgmlEngineTest, ViewsQ4_0ScalesOfMappedPackage) { // alternate fp16 scales 0.5 and 1.0. constexpr size_t kScaleBytes = 16; constexpr size_t kWeightBytes = 128; - std::string header = - R"({"weight_scale":{"dtype":"F16","shape":[1,8],"data_offsets":[0,16]},)" - R"("weight":{"dtype":"U8","shape":[128],"data_offsets":[16,144]}})"; + std::string header = kQ4_0SafeTensorsHeader; // The safetensors member comes first, so its payload starts right after // the local header, name, size prefix and JSON header. read_constant views // the scales in place only at an even offset. @@ -1522,7 +1556,7 @@ TEST(GgmlEngineTest, ViewsQ4_0ScalesOfMappedPackage) { ASSERT_TRUE(view.has_value()); ASSERT_EQ(reinterpret_cast(view->span().data()) % 2, misaligned); - auto host = GgmlEngineHost::create(); + auto host = GgmlEngineHost::create(kAllowActivationQuant); auto context = host->create_ggml_context( std::make_shared( Program::load(program_bytes.data(), program_bytes.size())), diff --git a/backends/native/test/runtime/ggml/test_ggml_ops.py b/backends/native/test/runtime/ggml/test_ggml_ops.py index 1635f5e7082..941a5de9268 100644 --- a/backends/native/test/runtime/ggml/test_ggml_ops.py +++ b/backends/native/test/runtime/ggml/test_ggml_ops.py @@ -305,6 +305,7 @@ def _run_ggml( max_cached_graphs: int = 1, graph_ops: collections.Counter | None = None, method: str = "forward", + allow_activation_quant_for_weight_only: bool = False, ): """Runs each step's inputs in order on one executable and returns each step's outputs as (bytes, shape) pairs. `graph_ops`, if given, collects @@ -334,6 +335,8 @@ def _run_ggml( command.append(f"--input_sizes={';'.join(map(_shape_text, steps))}") if graph_ops is not None: command.append("--print_graph_ops") + if allow_activation_quant_for_weight_only: + command.append("--allow_activation_quant_for_weight_only") with as_file(files(__package__) / "ggml_ptn_runner") as runner: result = subprocess.run( [str(runner), *command], @@ -387,15 +390,35 @@ def test_gguf_q6_k_linear(self): # block scale on GPUs. Test the weight layout tightly with exact # activations, then separately bound the randomized requantization loss. gpu = os.environ.get("GGML_PTN_DEVICE", "cpu") == "gpu" - self._check(model, (representable,), atol=5e-4, rtol=2e-4 if gpu else 1e-5) - self._check(model, (torch.randn(2, 256) / 8,), atol=0.35, rtol=0.02) + self._check( + model, + (representable,), + atol=5e-4, + rtol=2e-4 if gpu else 1e-5, + allow_activation_quant_for_weight_only=True, + ) + self._check( + model, + (torch.randn(2, 256) / 8,), + atol=0.35, + rtol=0.02, + allow_activation_quant_for_weight_only=True, + ) def test_gguf_q6_k_embedding(self): model = torch.nn.Embedding(8, 256) model.weight = torch.nn.Parameter(self._q6_k_weights(8), requires_grad=False) self._check(model, (torch.tensor([[0, 3, 6], [7, 1, 4]]),)) - def _check(self, model, inputs, atol=1e-5, rtol=1e-5, graph_ops=None): + def _check( + self, + model, + inputs, + atol=1e-5, + rtol=1e-5, + graph_ops=None, + allow_activation_quant_for_weight_only=False, + ): model = model.eval() # Eager writes buffers in place; the package must keep them as constructed. with torch.no_grad(): @@ -406,7 +429,14 @@ def _check(self, model, inputs, atol=1e-5, rtol=1e-5, graph_ops=None): package = outdir / "model.ptn" to_native(torch.export.export(model, inputs)).save(str(package)) (outputs,) = _run_ggml( - package, [inputs], outdir, len(expected), graph_ops=graph_ops + package, + [inputs], + outdir, + len(expected), + graph_ops=graph_ops, + allow_activation_quant_for_weight_only=( + allow_activation_quant_for_weight_only + ), ) self.assertEqual(len(outputs), len(expected)) for (raw, shape), reference in zip(outputs, expected): From 7526cf8c6bfde338e72de62e2767458f1cf6d39e Mon Sep 17 00:00:00 2001 From: Digant Desai Date: Tue, 3 Nov 2026 06:24:26 -0800 Subject: [PATCH 116/128] [executorch][native] Judge activation-quantized engines against an int8-activation golden Pull Request resolved: https://github.com/pytorch/executorch/pull/24362 With activation quantization allowed, ggml's weight-only linears no longer compute what `golden` describes. Weight-only packages now carry `golden-int8_act`, eager with int8 per-token dynamic activations, which `sample_test_main` judges against under `--allow_activation_quant_for_weight_only`. The flag is a `HostOptions` field that Vulkan rejects; new `ggml_sample_test_act_quant` targets and the `native_ggml_sample_test_act_quant` ctest set it. Authored with Claude Code. @bypass-github-export-checks @bypass-github-pytorch-ci-checks @bypass-github-executorch-ci-checks ghstack-source-id: 463657755 @exported-using-ghexport Differential Revision: [D128254888](https://our.internmc.facebook.com/intern/diff/D128254888/) --- backends/native/test/CMakeLists.txt | 6 +++++ backends/native/test/runtime/PtnRunner.h | 9 ++++++- backends/native/test/runtime/ggml/BUCK | 10 +++++-- .../test/runtime/ggml/ggml_ptn_runner.cpp | 8 ++---- .../native/test/runtime/ptn_runner_main.cpp | 8 +++++- .../test/runtime/vulkan/vulkan_ptn_runner.cpp | 9 ++++++- backends/native/test/samples/record.py | 23 +++++++++++++--- .../native/test/samples/sample_test_main.cpp | 26 +++++++++++++++---- backends/native/test/samples/schemes.py | 17 ++++++++++++ backends/native/test/samples/test_record.py | 19 ++++++++++++++ 10 files changed, 115 insertions(+), 20 deletions(-) diff --git a/backends/native/test/CMakeLists.txt b/backends/native/test/CMakeLists.txt index 719edc54578..83e144150aa 100644 --- a/backends/native/test/CMakeLists.txt +++ b/backends/native/test/CMakeLists.txt @@ -155,6 +155,12 @@ if(EXECUTORCH_BUILD_NATIVE_GGML) ) # TODO: Enable once every case passes. set_tests_properties(native_ggml_sample_test PROPERTIES DISABLED TRUE) + # native_ggml_sample_test_act_quant lets ggml quantize the activations of + # weight-only quantized linears, judged against golden-int8_act. + add_test(NAME native_ggml_sample_test_act_quant + COMMAND native_ggml_sample_test --packages=${_native_op_samples} + --device=cpu --allow_activation_quant_for_weight_only + ) target_compile_options( native_ggml_sample_test PRIVATE $<$:/EHsc> diff --git a/backends/native/test/runtime/PtnRunner.h b/backends/native/test/runtime/PtnRunner.h index de23aeb4da4..7bb03987f7a 100644 --- a/backends/native/test/runtime/PtnRunner.h +++ b/backends/native/test/runtime/PtnRunner.h @@ -12,8 +12,15 @@ namespace ptn::testing { +// Numerics a driver lets an engine trade away. Drivers then judge the engine as +// having made the trade, so an engine that never makes it rejects the option. +struct HostOptions { + // Quantize the activations of linears whose weights alone are quantized. + bool allow_activation_quant_for_weight_only = false; +}; + // Defined once per binary by an engine's host library; drivers such as // ptn_runner_main.cpp run every engine through the host it returns. -std::unique_ptr make_engine_host(); +std::unique_ptr make_engine_host(const HostOptions& options); } // namespace ptn::testing diff --git a/backends/native/test/runtime/ggml/BUCK b/backends/native/test/runtime/ggml/BUCK index 71d998be09e..a740ae2fcf1 100644 --- a/backends/native/test/runtime/ggml/BUCK +++ b/backends/native/test/runtime/ggml/BUCK @@ -91,14 +91,16 @@ _VARIANTS = [ for v in _VARIANTS ] +# ggml_sample_test_act_quant lets ggml quantize the activations of weight-only +# quantized linears, judged against golden-int8_act. [ fbcode_target( _kind = runtime.cxx_test, - name = "ggml_sample_test" + v["suffix"], + name = "ggml_sample_test" + name + v["suffix"], args = [ "--packages=$(location //executorch/backends/native/test/samples:op_samples)", "--device=" + v["device"], - ], + ] + flags, env = v.get("env", {}), remote_execution = v.get("remote_execution"), # TODO: run in CI once they pass. @@ -108,6 +110,10 @@ _VARIANTS = [ **v.get("compat", {}) ) for v in _VARIANTS + for name, flags in [ + ("", []), + ("_act_quant", ["--allow_activation_quant_for_weight_only"]), + ] ] [ diff --git a/backends/native/test/runtime/ggml/ggml_ptn_runner.cpp b/backends/native/test/runtime/ggml/ggml_ptn_runner.cpp index ad600382f04..9bff34d2f66 100644 --- a/backends/native/test/runtime/ggml/ggml_ptn_runner.cpp +++ b/backends/native/test/runtime/ggml/ggml_ptn_runner.cpp @@ -26,15 +26,11 @@ DEFINE_bool( print_graph_ops, false, "Print each built graph's node count per ggml op to stderr."); -DEFINE_bool( - allow_activation_quant_for_weight_only, - false, - "Run weight-only quantized linears with ggml's quantized activations."); namespace ptn::testing { // cppcheck-suppress unusedFunction -std::unique_ptr make_engine_host() { +std::unique_ptr make_engine_host(const HostOptions& host_options) { GgmlEngineOptions options; if (FLAGS_device == "gpu") { options.device = GgmlDevice::Gpu; @@ -49,7 +45,7 @@ std::unique_ptr make_engine_host() { options.max_cached_graphs = static_cast(FLAGS_max_cached_graphs); options.print_graph_ops = FLAGS_print_graph_ops; options.allow_activation_quant_for_weight_only = - FLAGS_allow_activation_quant_for_weight_only; + host_options.allow_activation_quant_for_weight_only; return GgmlEngineHost::create(options); } diff --git a/backends/native/test/runtime/ptn_runner_main.cpp b/backends/native/test/runtime/ptn_runner_main.cpp index 654ef0ad569..973aeec5025 100644 --- a/backends/native/test/runtime/ptn_runner_main.cpp +++ b/backends/native/test/runtime/ptn_runner_main.cpp @@ -65,6 +65,10 @@ DEFINE_int32( iterations, 1, "Timed executions; above one, prints median and min latency."); +DEFINE_bool( + allow_activation_quant_for_weight_only, + false, + "Let the engine quantize the activations of weight-only quantized linears."); namespace { @@ -201,7 +205,9 @@ void run() { const ptn::ByteSpan program_bytes = package->program_bytes(); const auto program = std::make_shared( ptn::Program::load(program_bytes.data(), program_bytes.size())); - const auto host = ptn::testing::make_engine_host(); + const auto host = ptn::testing::make_engine_host( + {.allow_activation_quant_for_weight_only = + FLAGS_allow_activation_quant_for_weight_only}); const auto context = host->create_context(program, package); const auto executable = context->compile(FLAGS_method); diff --git a/backends/native/test/runtime/vulkan/vulkan_ptn_runner.cpp b/backends/native/test/runtime/vulkan/vulkan_ptn_runner.cpp index 3f8b9e76ee0..51163c0d478 100644 --- a/backends/native/test/runtime/vulkan/vulkan_ptn_runner.cpp +++ b/backends/native/test/runtime/vulkan/vulkan_ptn_runner.cpp @@ -6,13 +6,20 @@ // The Vulkan engine host for PTN test drivers. +#include + #include #include namespace ptn::testing { // cppcheck-suppress unusedFunction -std::unique_ptr make_engine_host() { +std::unique_ptr make_engine_host(const HostOptions& options) { + if (options.allow_activation_quant_for_weight_only) { + throw std::invalid_argument( + "vulkan: never quantizes activations, so it rejects " + "allow_activation_quant_for_weight_only"); + } return VulkanEngineHost::create(); } diff --git a/backends/native/test/samples/record.py b/backends/native/test/samples/record.py index 6846890f081..d3565a01667 100644 --- a/backends/native/test/samples/record.py +++ b/backends/native/test/samples/record.py @@ -9,9 +9,10 @@ A package holds one method per case and dtype under one quantization scheme, and two sample sets over the same inputs: `golden`, the eager outputs, and `precise`, the same math in float64 with weights dequantized and activations -unquantized, floating outputs stored as float32. Nothing in it depends on an -engine or a device: consumers judge an engine by its distance from `precise` -relative to eager's. +unquantized, floating outputs stored as float32. Weight-only schemes add +`golden-int8_act`, the eager outputs with the same weights and int8 per-token +dynamic activations. Nothing in it depends on an engine or a device: consumers +judge an engine by its distance from `precise` relative to eager's. """ import copy @@ -50,7 +51,8 @@ def record( dtypes: Iterable[str] = tuple(DTYPES), ) -> None: """Writes no package, removing any at `path`, when the scheme fits no case.""" - programs, golden, precise = {}, [], [] + act_scheme = scheme.act_quant() + programs, golden, precise, golden_act = {}, [], [], [] for case in cases: if not scheme.fits(case.model): logger.info(f"{path.name}: {case.name} does not fit the scheme") @@ -63,6 +65,9 @@ def record( model = copy.deepcopy(case.model).to(dtype).eval() scheme.quantize(model) method = _Method({name: model}) + if act_scheme is not None: + act_model = copy.deepcopy(case.model).to(dtype).eval() + act_scheme.quantize(act_model) state = { k: scheme.decode(v).double() for k, v in method.state_dict().items() } @@ -76,6 +81,10 @@ def record( t.float() if t.is_floating_point() else t for t in outputs(ref) ] precise.append(Sample([Call(name, list(x), ref)])) + if act_scheme is not None: + golden_act.append( + Sample([Call(name, list(x), outputs(act_model(*x)))]) + ) if not programs: logger.info(f"{path.name}: no case fits the scheme, not written") path.unlink(missing_ok=True) @@ -90,3 +99,9 @@ def record( precise, "float64, weights dequantized, activations unquantized", source ), ) + if act_scheme is not None: + write_samples( + str(path), + "golden-int8_act", + SampleSet(golden_act, "eager, int8 per-token dynamic activations", source), + ) diff --git a/backends/native/test/samples/sample_test_main.cpp b/backends/native/test/samples/sample_test_main.cpp index 584676a49f6..e44d987343c 100644 --- a/backends/native/test/samples/sample_test_main.cpp +++ b/backends/native/test/samples/sample_test_main.cpp @@ -11,7 +11,8 @@ // // Usage: // --packages=a.ptn,dir [--rmse_ratio --max_ratio --rtol --sigmas] -// [engine host flags] [gtest flags] +// [--allow_activation_quant_for_weight_only] [engine host flags] +// [gtest flags] #include #include @@ -56,6 +57,11 @@ DEFINE_double( sigmas, ptn::testing::Policy{}.sigmas, "Standard errors the fitted biases may deviate by."); +DEFINE_bool( + allow_activation_quant_for_weight_only, + false, + "Let the engine quantize the activations of weight-only quantized linears, " + "judging it against golden-int8_act samples where a package has them."); namespace ptn::testing { namespace { @@ -63,6 +69,7 @@ namespace { struct Bundle { std::shared_ptr package; std::shared_ptr program; + std::string golden_set; SampleSet golden; SampleSet precise; }; @@ -70,7 +77,9 @@ struct Bundle { // Created on first use, so listing tests touches no device. EngineHost& host() { static const std::unique_ptr instance = [] { - std::unique_ptr made = make_engine_host(); + std::unique_ptr made = make_engine_host( + {.allow_activation_quant_for_weight_only = + FLAGS_allow_activation_quant_for_weight_only}); std::printf( "engine %s on %s\n", made->name().c_str(), made->device_name().c_str()); return made; @@ -111,7 +120,8 @@ class SampleTest : public ::testing::Test { .rtol = FLAGS_rtol, .sigmas = FLAGS_sigmas}; for (const auto& [key, check] : checks) { - const std::string name = key.first + " out " + std::to_string(key.second); + const std::string name = key.first + " out " + + std::to_string(key.second) + " vs " + bundle_->golden_set; std::printf("%s: %s\n", name.c_str(), check.summary().c_str()); for (const std::string& failure : check.failures(policy)) { ADD_FAILURE() << name << ": " << failure; @@ -129,7 +139,8 @@ class SampleTest : public ::testing::Test { const SampleCall& call = golden.calls[c]; ASSERT_EQ(call.method, precise.calls[c].method); ASSERT_TRUE(call.inputs == precise.calls[c].inputs) - << call.method << " has golden and precise inputs that differ"; + << call.method << " has " << bundle_->golden_set + << " and precise inputs that differ"; ASSERT_TRUE(call.outputs && precise.calls[c].outputs) << call.method << " has no recorded outputs"; const std::vector& want = *call.outputs; @@ -208,7 +219,12 @@ void register_package(const std::filesystem::path& path) { bundle->program = std::make_shared( Program::load(program.data(), program.size())); const ZipReader zip = ZipReader::open(path.string()); - bundle->golden = read_sample_set(zip, "golden"); + // Eager outputs with int8 per-token dynamic activations, which packages with + // weight-only quantized linears also carry. + const bool act_quant_golden = FLAGS_allow_activation_quant_for_weight_only && + zip.member_size("samples/golden-int8_act/index.json").has_value(); + bundle->golden_set = act_quant_golden ? "golden-int8_act" : "golden"; + bundle->golden = read_sample_set(zip, bundle->golden_set); bundle->precise = read_sample_set(zip, "precise"); std::set methods; diff --git a/backends/native/test/samples/schemes.py b/backends/native/test/samples/schemes.py index 8966e7facad..82d9c2d8cec 100644 --- a/backends/native/test/samples/schemes.py +++ b/backends/native/test/samples/schemes.py @@ -33,6 +33,11 @@ def decode(self, value: torch.Tensor) -> torch.Tensor: """A state tensor of a quantized model as a plain tensor.""" return value + def act_quant(self) -> "Scheme | None": + """This weight-only scheme's weights with int8 dynamic activations, or + None when the scheme is not weight-only.""" + return None + def _fits(model: torch.nn.Module, group: int | None) -> bool: """Whether `model` has an nn.Linear and every one's in_features split into @@ -68,6 +73,18 @@ def quantize(self, model: torch.nn.Module) -> None: def decode(self, value: torch.Tensor) -> torch.Tensor: return value.dequantize() if isinstance(value, TorchAOBaseTensor) else value + def act_quant(self) -> Scheme | None: + if not isinstance(self.config, IntxWeightOnlyConfig): + return None + return Torchao( + Int8DynamicActivationIntxWeightConfig( + weight_dtype=self.config.weight_dtype, + weight_granularity=self.config.granularity, + weight_mapping_type=self.config.mapping_type, + weight_scale_dtype=self.config.scale_dtype, + ) + ) + def schemes() -> dict[str, Scheme]: return { diff --git a/backends/native/test/samples/test_record.py b/backends/native/test/samples/test_record.py index 391ecd9b52a..086f2a55d9b 100644 --- a/backends/native/test/samples/test_record.py +++ b/backends/native/test/samples/test_record.py @@ -77,6 +77,25 @@ def test_record_golden_is_eager_and_precise_is_float64(self) -> None: atol=0, ) + def test_record_adds_the_8da4w_golden_to_weight_only_packages(self) -> None: + case = Case("linear", torch.nn.Linear(64, 8), draws(2, (3, 64))) + sets = {} + with tempfile.TemporaryDirectory() as tmp: + for name in ("float", "int4wo_g32", "8da4w_g32"): + path = Path(tmp) / f"{name}.ptn" + record([case], schemes()[name], path, dtypes=["f32"]) + sets[name] = read_samples(str(path)) + self.assertNotIn("golden-int8_act", sets["float"]) + self.assertNotIn("golden-int8_act", sets["8da4w_g32"]) + for got, want in zip( + sets["int4wo_g32"]["golden-int8_act"].samples, + sets["8da4w_g32"]["golden"].samples, + strict=True, + ): + torch.testing.assert_close( + got.calls[0].outputs, want.calls[0].outputs, rtol=0, atol=0 + ) + def test_record_keeps_integer_outputs_precise(self) -> None: class Argmax(torch.nn.Module): def forward(self, x: torch.Tensor) -> torch.Tensor: From 29b275f277f69e35056152a17381837c24645517 Mon Sep 17 00:00:00 2001 From: Digant Desai Date: Tue, 3 Nov 2026 06:24:26 -0800 Subject: [PATCH 117/128] [executorch][native-ggml] Compute cuBLAS GEMMs in f32 in the CUDA sample tests Pull Request resolved: https://github.com/pytorch/executorch/pull/24363 By default ggml-CUDA's cuBLAS GEMMs compute in f16 for f16 and dequantized weights, and in TF32 for f32 ones, so the linear samples measured those defaults rather than ggml's kernels. The CUDA sample-test targets set `GGML_CUDA_CUBLAS_COMPUTE_TYPE=f32` and `NVIDIA_TF32_OVERRIDE=0`; llama.cpp's defaults, `test_ggml_ops` and the bundle are unchanged. Authored with Claude Code. Differential Revision: [D128254889](https://our.internmc.facebook.com/intern/diff/D128254889/) **NOTE FOR REVIEWERS**: This PR has internal Meta-specific changes or comments, please review them on [Phabricator](https://our.internmc.facebook.com/intern/diff/D128254889/)! ghstack-source-id: 463657744 --- backends/native/test/runtime/ggml/BUCK | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/backends/native/test/runtime/ggml/BUCK b/backends/native/test/runtime/ggml/BUCK index a740ae2fcf1..ad23a997116 100644 --- a/backends/native/test/runtime/ggml/BUCK +++ b/backends/native/test/runtime/ggml/BUCK @@ -45,6 +45,11 @@ _VARIANTS = [ # Buck does not put the CUDA driver stub's library on the loader path. # @oss-disable[end= ]: "LD_PRELOAD": "$(location //tools/lazify:lazy_stub_binder[shared])", }, + # ggml-CUDA computes f16 BLAS GEMMs in f16 and f32 ones in TF32 by default. + "sample_env": { + "GGML_CUDA_CUBLAS_COMPUTE_TYPE": "f32", + "NVIDIA_TF32_OVERRIDE": "0", + }, }, { "suffix": "_hip", @@ -101,7 +106,7 @@ _VARIANTS = [ "--packages=$(location //executorch/backends/native/test/samples:op_samples)", "--device=" + v["device"], ] + flags, - env = v.get("env", {}), + env = v.get("env", {}) | v.get("sample_env", {}), remote_execution = v.get("remote_execution"), # TODO: run in CI once they pass. labels = ci.labels(ci.map(ci.skip_test())), From b548c687f1724d67247921f700f4dbe7db97b7b4 Mon Sep 17 00:00:00 2001 From: Digant Desai Date: Tue, 3 Nov 2026 06:24:27 -0800 Subject: [PATCH 118/128] [executorch][native] Suggest rounding to nearest when engines narrow values Pull Request resolved: https://github.com/pytorch/executorch/pull/24364 Rounding toward zero when narrowing f32 to f16, which Vulkan and WebGPU allow, biases every stored value toward zero, and the bias compounds across layers. The README suggests rounding to nearest, ties to even, as eager does. Authored with Claude Code. @bypass-github-export-checks @bypass-github-pytorch-ci-checks @bypass-github-executorch-ci-checks ghstack-source-id: 463657754 @exported-using-ghexport Differential Revision: [D128254885](https://our.internmc.facebook.com/intern/diff/D128254885/) --- backends/native/README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/backends/native/README.md b/backends/native/README.md index 8a2d76ce20a..d3c9e87f283 100644 --- a/backends/native/README.md +++ b/backends/native/README.md @@ -145,6 +145,14 @@ an engine through the interfaces in `runtime/engine/Engine.h`: An engine rejects a method whose semantics it cannot fully honor. +Engines need not match eager bit for bit: accumulation precision and order, +fused multiply-add, and the accuracy of functions such as `exp` are up to each +engine. As a guideline, an engine should round to nearest when it narrows a +value for storage, with ties to even between floating-point types (f32 to f16 or +bf16), as eager does. Rounding toward zero, which some GPU APIs allow, biases +every stored value toward zero, and the bias compounds across layers instead of +averaging out. + | Engine | Source | Notes | | --- | --- | --- | | Vulkan | `runtime/vulkan/` | Builds on the ET-VK `ComputeGraph` and shaders. | From 1ff81304b19acce659db17cda7835441f8999cff Mon Sep 17 00:00:00 2001 From: Digant Desai Date: Tue, 3 Nov 2026 06:24:28 -0800 Subject: [PATCH 119/128] [executorch][native] Add FACTO-drawn linear cases to the op samples Pull Request resolved: https://github.com/pytorch/executorch/pull/24365 The hand-written cases cover few shapes. `facto_cases` turns the argument tuples FACTO draws for an op spec from a fixed seed into one-sample cases, `facto_`, and `linear.py` keeps every fifth `linear.default` tuple. The spec lives here, so ET backend tests are unchanged. The `native_op_samples` CMake target puts the vendored FACTO submodule on `PYTHONPATH` and reruns when its sources change. Authored with Claude Code. @bypass-github-export-checks @bypass-github-pytorch-ci-checks @bypass-github-executorch-ci-checks ghstack-source-id: 463657759 @exported-using-ghexport Differential Revision: [D128254881](https://our.internmc.facebook.com/intern/diff/D128254881/) --- backends/native/README.md | 5 +- backends/native/test/CMakeLists.txt | 8 ++- backends/native/test/samples/BUCK | 1 + backends/native/test/samples/cases.py | 22 ++++++++ backends/native/test/samples/linear.py | 77 ++++++++++++++++++++++++-- 5 files changed, 104 insertions(+), 9 deletions(-) diff --git a/backends/native/README.md b/backends/native/README.md index d3c9e87f283..c918183cddc 100644 --- a/backends/native/README.md +++ b/backends/native/README.md @@ -217,8 +217,9 @@ enabled, `native_runner_utils_test` covers their shared runner utilities. Run th with `ctest --test-dir cmake-out -R native_` after `cmake --build cmake-out`. The engines' sample tests judge them on the bundled op samples. Building them runs -the sample generator, which needs `torch` and `torchao`. Those with failing cases -are disabled in CTest; run their binaries directly with +the sample generator, which needs `torch`, `torchao` and the FACTO submodule at +`backends/cadence/utils/FACTO`. Those with failing cases are disabled in CTest; +run their binaries directly with `--packages=/backends/native/test/op_samples`. The `Test Native Engines` CI workflow has separate Linux jobs for the shared diff --git a/backends/native/test/CMakeLists.txt b/backends/native/test/CMakeLists.txt index 83e144150aa..523d450ba50 100644 --- a/backends/native/test/CMakeLists.txt +++ b/backends/native/test/CMakeLists.txt @@ -48,16 +48,18 @@ endforeach() # environment pins the eager kernels' ISA and threads, so the golden is the same # on any x86 host with AVX2. Other hosts can differ. set(_native_op_samples ${CMAKE_CURRENT_BINARY_DIR}/op_samples) -file(GLOB _native_op_samples_srcs CONFIGURE_DEPENDS +file(GLOB_RECURSE _native_op_samples_srcs CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/samples/*.py + ${EXECUTORCH_ROOT}/backends/cadence/utils/FACTO/facto/*.py ) add_custom_command( OUTPUT ${_native_op_samples}.stamp COMMAND ${CMAKE_COMMAND} -E env MKL_CBWR=COMPATIBLE ONEDNN_MAX_CPU_ISA=AVX2 ATEN_CPU_CAPABILITY=avx2 OMP_NUM_THREADS=1 MKL_NUM_THREADS=1 - "PYTHONPATH=${EXECUTORCH_ROOT}/src" ${PYTHON_EXECUTABLE} -m - executorch.backends.native.test.samples.generate --out ${_native_op_samples} + "PYTHONPATH=${EXECUTORCH_ROOT}/src:${EXECUTORCH_ROOT}/backends/cadence/utils/FACTO" + ${PYTHON_EXECUTABLE} -m executorch.backends.native.test.samples.generate + --out ${_native_op_samples} COMMAND ${CMAKE_COMMAND} -E touch ${_native_op_samples}.stamp DEPENDS ${_native_op_samples_srcs} ${EXECUTORCH_ROOT}/backends/test/suite/operators/test_linear.py diff --git a/backends/native/test/samples/BUCK b/backends/native/test/samples/BUCK index 122272f1d3e..326d33d26de 100644 --- a/backends/native/test/samples/BUCK +++ b/backends/native/test/samples/BUCK @@ -18,6 +18,7 @@ fbcode_target( "//executorch/backends/test/suite/operators:test_operators", "//executorch/exir/native:native", "//pytorch/ao:torchao", + "//pytorch/facto:facto", ], ) diff --git a/backends/native/test/samples/cases.py b/backends/native/test/samples/cases.py index b08baba963a..82b97316b24 100644 --- a/backends/native/test/samples/cases.py +++ b/backends/native/test/samples/cases.py @@ -7,11 +7,16 @@ """Op cases: a model and the inputs of each sample it is recorded with.""" import inspect +from collections.abc import Callable from dataclasses import dataclass from types import ModuleType +from typing import TYPE_CHECKING import torch +if TYPE_CHECKING: + from facto.inputgen.specs.model import Spec + Inputs = tuple[torch.Tensor, ...] # Caps a suite case's input and output elements over its samples, keeping at @@ -85,3 +90,20 @@ def suite_cases(module: ModuleType, samples: int) -> list[Case]: if not cases: raise ValueError(f"no test in {module.__name__} lowers a model") return cases + + +def facto_cases( + spec: "Spec", build: Callable[..., Case], keep: Callable[[int, tuple], bool] +) -> list[Case]: + """One-sample cases `build(name, *args)` from FACTO's argument tuples for + `spec`, named by position, keeping those where `keep(position, args)`.""" + # FACTO is on the path for the sample generator only, not for pytest. + from facto.inputgen.argtuple.gen import ArgumentTupleGenerator + from facto.inputgen.utils.random_manager import seeded_random_manager + + seeded_random_manager.seed(0) + return [ + build(f"facto_{i}", *args) + for i, (args, _, _) in enumerate(ArgumentTupleGenerator(spec).gen()) + if keep(i, args) + ] diff --git a/backends/native/test/samples/linear.py b/backends/native/test/samples/linear.py index 24c39fe2ccc..c1f1dc60e48 100644 --- a/backends/native/test/samples/linear.py +++ b/backends/native/test/samples/linear.py @@ -4,12 +4,76 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. -"""Linear cases: the backend test suite's, and shapes and inputs that stress -accumulation and quantization.""" +"""Linear cases: the backend test suite's, FACTO's, and shapes and inputs that +stress accumulation and quantization.""" import torch -from executorch.backends.native.test.samples.cases import Case, draws, suite_cases +from executorch.backends.native.test.samples.cases import ( + Case, + draws, + facto_cases, + suite_cases, +) from executorch.backends.test.suite.operators import test_linear +from facto.inputgen.argument.type import ArgType +from facto.inputgen.specs.model import ConstraintProducer as cp, InPosArg, OutArg, Spec + + +def _unit(*constraints) -> list: + """Float32 values in [-1, 1] under `constraints`.""" + return [ + cp.Dtype.Eq(lambda deps: torch.float32), + *constraints, + cp.Value.Ge(lambda deps, dtype, struct: -1), + cp.Value.Le(lambda deps, dtype, struct: 1), + ] + + +# in_features is a multiple of every scheme's group size, and out_features is +# large enough to fit an error's abs_bias and rel_bias from one sample. +_FACTO_SPEC = Spec( + op="linear.default", + inspec=[ + InPosArg( + ArgType.Tensor, + name="input", + deps=[1], + constraints=_unit( + cp.Rank.Ge(lambda deps: 1), + cp.Rank.Le(lambda deps: 3), + cp.Size.In( + lambda deps, r, d: [deps[0].size(1)] if d == r - 1 else range(1, 9) + ), + ), + ), + InPosArg( + ArgType.Tensor, + name="weight", + constraints=_unit( + cp.Rank.Eq(lambda deps: 2), + cp.Size.In(lambda deps, r, d: [128, 256, 384] if d else [16, 48, 96]), + ), + ), + InPosArg( + ArgType.TensorOpt, + name="bias", + deps=[1], + constraints=_unit( + cp.Rank.Eq(lambda deps: 1), + cp.Size.Eq(lambda deps, r, d: deps[0].size(0)), + ), + ), + ], + outspec=[OutArg(ArgType.Tensor)], +) + + +def _facto_case(name: str, x, weight, bias) -> Case: + model = torch.nn.Linear(weight.size(1), weight.size(0), bias=bias is not None) + model.weight.data = weight + if bias is not None: + model.bias.data = bias + return Case(name, model, [(x,)]) def cases() -> list[Case]: @@ -22,7 +86,8 @@ def cases() -> list[Case]: zero_row = draws(2, (8, 256)) for (x,) in zero_row: x[2] = 0 - return suite + [ + return [ + *suite, Case("decode", torch.nn.Linear(2048, 512), draws(8, (1, 2048))), Case("prefill", torch.nn.Linear(1024, 256), draws(2, (64, 1024))), Case("large_k", torch.nn.Linear(8192, 32), draws(4, (4, 8192))), @@ -33,4 +98,8 @@ def cases() -> list[Case]: ), Case("outlier_channel", torch.nn.Linear(256, 128), outlier), Case("zero_row", torch.nn.Linear(256, 64), zero_row), + # FACTO varies one argument at a time, so only every fifth tuple is kept. + # Its values lie on a 1/8 grid, which makes float32 math exact: these + # cases cover shapes more than rounding. + *facto_cases(_FACTO_SPEC, _facto_case, lambda i, _: i % 5 == 0), ] From 7202d58955807ceedd259cc7d238f6701fc37353 Mon Sep 17 00:00:00 2001 From: Digant Desai Date: Tue, 3 Nov 2026 06:24:28 -0800 Subject: [PATCH 120/128] [executorch][native] Add GGUF q4_k and q6_k packages to the op samples Pull Request resolved: https://github.com/pytorch/executorch/pull/24366 PTN carries GGUF k-quant constants, but no op package exercised them. A `Gguf` scheme, as `gguf_q4_k` and `gguf_q6_k`, writes random valid blocks to a .gguf with gguf-py and loads them back as a checkpoint would. `golden-int8_act` now comes from per-token int8 pre-hooks on the weight-only model, which also work for GGUF, so `Scheme.weight_only` replaces `Scheme.act_quant`. FACTO also keeps the tuples whose input features fit 256-element blocks. Authored with Claude Code. Differential Revision: [D128254890](https://our.internmc.facebook.com/intern/diff/D128254890/) **NOTE FOR REVIEWERS**: This PR has internal Meta-specific changes or comments, please review them on [Phabricator](https://our.internmc.facebook.com/intern/diff/D128254890/)! ghstack-source-id: 463657745 --- backends/native/README.md | 6 +- backends/native/test/samples/BUCK | 3 + backends/native/test/samples/linear.py | 19 ++-- backends/native/test/samples/record.py | 17 ++-- backends/native/test/samples/schemes.py | 114 +++++++++++++++++++++--- 5 files changed, 126 insertions(+), 33 deletions(-) diff --git a/backends/native/README.md b/backends/native/README.md index c918183cddc..33e5d73cf24 100644 --- a/backends/native/README.md +++ b/backends/native/README.md @@ -217,9 +217,9 @@ enabled, `native_runner_utils_test` covers their shared runner utilities. Run th with `ctest --test-dir cmake-out -R native_` after `cmake --build cmake-out`. The engines' sample tests judge them on the bundled op samples. Building them runs -the sample generator, which needs `torch`, `torchao` and the FACTO submodule at -`backends/cadence/utils/FACTO`. Those with failing cases are disabled in CTest; -run their binaries directly with +the sample generator, which needs `torch`, `torchao`, `gguf` and the FACTO +submodule at `backends/cadence/utils/FACTO`. Those with failing cases are +disabled in CTest; run their binaries directly with `--packages=/backends/native/test/op_samples`. The `Test Native Engines` CI workflow has separate Linux jobs for the shared diff --git a/backends/native/test/samples/BUCK b/backends/native/test/samples/BUCK index 326d33d26de..d1d4ab455ec 100644 --- a/backends/native/test/samples/BUCK +++ b/backends/native/test/samples/BUCK @@ -17,8 +17,11 @@ fbcode_target( "//caffe2:torch", "//executorch/backends/test/suite/operators:test_operators", "//executorch/exir/native:native", + "//executorch/extension/llm/export:gguf", "//pytorch/ao:torchao", "//pytorch/facto:facto", + "fbsource//third-party/pypi/gguf:gguf", + "fbsource//third-party/pypi/numpy:numpy", ], ) diff --git a/backends/native/test/samples/linear.py b/backends/native/test/samples/linear.py index c1f1dc60e48..9ee902e681c 100644 --- a/backends/native/test/samples/linear.py +++ b/backends/native/test/samples/linear.py @@ -15,6 +15,7 @@ suite_cases, ) from executorch.backends.test.suite.operators import test_linear +from executorch.extension.llm.export.gguf import QK_K from facto.inputgen.argument.type import ArgType from facto.inputgen.specs.model import ConstraintProducer as cp, InPosArg, OutArg, Spec @@ -29,8 +30,9 @@ def _unit(*constraints) -> list: ] -# in_features is a multiple of every scheme's group size, and out_features is -# large enough to fit an error's abs_bias and rel_bias from one sample. +# in_features is a multiple of every torchao scheme's group size and, in some +# tuples, of GGUF's block; out_features is large enough to fit an error's +# abs_bias and rel_bias from one sample. _FACTO_SPEC = Spec( op="linear.default", inspec=[ @@ -98,8 +100,13 @@ def cases() -> list[Case]: ), Case("outlier_channel", torch.nn.Linear(256, 128), outlier), Case("zero_row", torch.nn.Linear(256, 64), zero_row), - # FACTO varies one argument at a time, so only every fifth tuple is kept. - # Its values lie on a 1/8 grid, which makes float32 math exact: these - # cases cover shapes more than rounding. - *facto_cases(_FACTO_SPEC, _facto_case, lambda i, _: i % 5 == 0), + # FACTO varies one argument at a time, so only every fifth tuple is kept, + # plus those whose in_features fit GGUF blocks. Its values lie on a 1/8 + # grid, which makes float32 math exact: these cases cover shapes more + # than rounding. + *facto_cases( + _FACTO_SPEC, + _facto_case, + lambda i, args: i % 5 == 0 or args[1].size(1) % QK_K == 0, + ), ] diff --git a/backends/native/test/samples/record.py b/backends/native/test/samples/record.py index d3565a01667..ef439ce541f 100644 --- a/backends/native/test/samples/record.py +++ b/backends/native/test/samples/record.py @@ -22,7 +22,7 @@ import torch from executorch.backends.native.test.samples.cases import Case, Inputs, outputs -from executorch.backends.native.test.samples.schemes import Scheme +from executorch.backends.native.test.samples.schemes import int8_activations, Scheme from executorch.exir.native import Call, Sample, SampleSet, to_native, write_samples from torch.func import functional_call @@ -51,7 +51,6 @@ def record( dtypes: Iterable[str] = tuple(DTYPES), ) -> None: """Writes no package, removing any at `path`, when the scheme fits no case.""" - act_scheme = scheme.act_quant() programs, golden, precise, golden_act = {}, [], [], [] for case in cases: if not scheme.fits(case.model): @@ -65,9 +64,6 @@ def record( model = copy.deepcopy(case.model).to(dtype).eval() scheme.quantize(model) method = _Method({name: model}) - if act_scheme is not None: - act_model = copy.deepcopy(case.model).to(dtype).eval() - act_scheme.quantize(act_model) state = { k: scheme.decode(v).double() for k, v in method.state_dict().items() } @@ -81,10 +77,11 @@ def record( t.float() if t.is_floating_point() else t for t in outputs(ref) ] precise.append(Sample([Call(name, list(x), ref)])) - if act_scheme is not None: - golden_act.append( - Sample([Call(name, list(x), outputs(act_model(*x)))]) - ) + if scheme.weight_only: + with int8_activations(method): + golden_act.append( + Sample([Call(name, list(x), outputs(method(*x)))]) + ) if not programs: logger.info(f"{path.name}: no case fits the scheme, not written") path.unlink(missing_ok=True) @@ -99,7 +96,7 @@ def record( precise, "float64, weights dequantized, activations unquantized", source ), ) - if act_scheme is not None: + if scheme.weight_only: write_samples( str(path), "golden-int8_act", diff --git a/backends/native/test/samples/schemes.py b/backends/native/test/samples/schemes.py index 82d9c2d8cec..af4183da9b1 100644 --- a/backends/native/test/samples/schemes.py +++ b/backends/native/test/samples/schemes.py @@ -7,9 +7,15 @@ """Quantization schemes. Every op is recorded under every scheme, keeping the cases the scheme fits.""" +import contextlib +import tempfile +from collections.abc import Iterator from dataclasses import dataclass +import numpy as np import torch +from executorch.extension.llm.export.gguf import iter_gguf, QK_K +from gguf import GGML_QUANT_SIZES, GGMLQuantizationType, GGUFWriter from torchao.core.config import AOBaseConfig from torchao.quantization import ( Int8DynamicActivationIntxWeightConfig, @@ -17,12 +23,24 @@ quantize_, ) from torchao.quantization.granularity import PerGroup + +# Private, but it is the input quantize-dequantize that +# Int8DynamicActivationIntxWeightConfig linears run. +from torchao.quantization.quantize_.workflows.intx.intx_unpacked_to_int8_tensor import ( + _apply_int8_act_asym_per_token_quant_dequant, +) from torchao.utils import TorchAOBaseTensor class Scheme: """No quantization.""" + @property + def weight_only(self) -> bool: + """Whether the scheme quantizes only weights; `record` then adds + `golden-int8_act`.""" + return False + def fits(self, model: torch.nn.Module) -> bool: return True @@ -33,11 +51,6 @@ def decode(self, value: torch.Tensor) -> torch.Tensor: """A state tensor of a quantized model as a plain tensor.""" return value - def act_quant(self) -> "Scheme | None": - """This weight-only scheme's weights with int8 dynamic activations, or - None when the scheme is not weight-only.""" - return None - def _fits(model: torch.nn.Module, group: int | None) -> bool: """Whether `model` has an nn.Linear and every one's in_features split into @@ -57,6 +70,10 @@ class Torchao(Scheme): config: AOBaseConfig + @property + def weight_only(self) -> bool: + return isinstance(self.config, IntxWeightOnlyConfig) + def fits(self, model: torch.nn.Module) -> bool: granularity = ( self.config.granularity @@ -73,17 +90,84 @@ def quantize(self, model: torch.nn.Module) -> None: def decode(self, value: torch.Tensor) -> torch.Tensor: return value.dequantize() if isinstance(value, TorchAOBaseTensor) else value - def act_quant(self) -> Scheme | None: - if not isinstance(self.config, IntxWeightOnlyConfig): - return None - return Torchao( - Int8DynamicActivationIntxWeightConfig( - weight_dtype=self.config.weight_dtype, - weight_granularity=self.config.granularity, - weight_mapping_type=self.config.mapping_type, - weight_scale_dtype=self.config.scale_dtype, + +# fp16 super-block scales by byte offset in each block, kept in fp16's normal +# range; q4_k's dmin of 8 d centers random weights near zero. +_GGUF_SUPER_SCALES = {"q4_k": {0: 2**-12, 2: 2**-9}, "q6_k": {208: 2**-12}} + + +def _random_blocks( + ggml_type: str, rows: int, cols: int, rng: np.random.Generator +) -> np.ndarray: + """Valid `ggml_type` blocks for a [rows, cols] weight from random bytes.""" + _, block_bytes = GGML_QUANT_SIZES[GGMLQuantizationType[ggml_type.upper()]] + raw = rng.integers(0, 256, (rows, cols // QK_K, block_bytes), dtype=np.uint8) + for offset, scale in _GGUF_SUPER_SCALES[ggml_type].items(): + raw[..., offset : offset + 2] = np.array([scale], np.float16).view(np.uint8) + return raw.reshape(rows, -1) + + +def _load_gguf(model: torch.nn.Module, ggml_type: str) -> None: + """Writes random `ggml_type` weights for every nn.Linear to a .gguf file and + loads them back as a GGUF checkpoint would be.""" + linears = [m for m in model.modules() if isinstance(m, torch.nn.Linear)] + rng = np.random.default_rng(0) + with tempfile.TemporaryDirectory() as tmp: + path = f"{tmp}/weights.gguf" + writer = GGUFWriter(path, arch="linear") + for i, m in enumerate(linears): + writer.add_tensor( + f"linear.{i}.weight", + _random_blocks(ggml_type, *m.weight.shape, rng), + raw_dtype=GGMLQuantizationType[ggml_type.upper()], + ) + writer.write_header_to_file() + writer.write_kv_data_to_file() + writer.write_tensors_to_file() + writer.close() + for m, (_, weight) in zip(linears, iter_gguf(path), strict=True): + m.weight = torch.nn.Parameter( + weight.to(m.weight.dtype), requires_grad=False ) + + +@dataclass(frozen=True) +class Gguf(Scheme): + """Weight-only GGUF k-quant blocks (`q4_k` or `q6_k`) for every nn.Linear, + loaded from a .gguf file.""" + + ggml_type: str + + @property + def weight_only(self) -> bool: + return True + + def fits(self, model: torch.nn.Module) -> bool: + return _fits(model, QK_K) + + def quantize(self, model: torch.nn.Module) -> None: + _load_gguf(model, self.ggml_type) + + def decode(self, value: torch.Tensor) -> torch.Tensor: + return value.dequantize() if isinstance(value, TorchAOBaseTensor) else value + + +@contextlib.contextmanager +def int8_activations(model: torch.nn.Module) -> Iterator[None]: + """Quantizes every nn.Linear input as torchao's int8 dynamic activation + schemes do: int8, asymmetric, per token.""" + hooks = [ + m.register_forward_pre_hook( + lambda _, args: (_apply_int8_act_asym_per_token_quant_dequant(args[0]),) ) + for m in model.modules() + if isinstance(m, torch.nn.Linear) + ] + try: + yield + finally: + for hook in hooks: + hook.remove() def schemes() -> dict[str, Scheme]: @@ -105,4 +189,6 @@ def schemes() -> dict[str, Scheme]: weight_dtype=torch.int4, weight_granularity=PerGroup(128) ) ), + "gguf_q4_k": Gguf("q4_k"), + "gguf_q6_k": Gguf("q6_k"), } From f35b111c349f98b9ff810e624284d7a123f94921 Mon Sep 17 00:00:00 2001 From: Digant Desai Date: Tue, 3 Nov 2026 06:24:29 -0800 Subject: [PATCH 121/128] [executorch][native] Decode quantized weights in float32 for precise samples Pull Request resolved: https://github.com/pytorch/executorch/pull/24367 `precise` used weights dequantized in the method's dtype, so f16 and bf16 references held already-rounded weights while engines decode in float32. The `Gguf` and `Torchao` schemes now decode their weights in float32 before the float64 math and raise on any other torchao weight. Authored with Claude Code. @bypass-github-export-checks @bypass-github-pytorch-ci-checks @bypass-github-executorch-ci-checks ghstack-source-id: 463657760 @exported-using-ghexport Differential Revision: [D128254893](https://our.internmc.facebook.com/intern/diff/D128254893/) --- backends/native/test/samples/BUCK | 1 + backends/native/test/samples/record.py | 15 ++++--- backends/native/test/samples/schemes.py | 20 +++++++-- backends/native/test/samples/test_record.py | 50 ++++++++++++++++++++- 4 files changed, 75 insertions(+), 11 deletions(-) diff --git a/backends/native/test/samples/BUCK b/backends/native/test/samples/BUCK index d1d4ab455ec..6ff24d67abc 100644 --- a/backends/native/test/samples/BUCK +++ b/backends/native/test/samples/BUCK @@ -41,6 +41,7 @@ fbcode_target( ":record", "//caffe2:torch", "//executorch/exir/native:native", + "//pytorch/ao:torchao", ], ) diff --git a/backends/native/test/samples/record.py b/backends/native/test/samples/record.py index ef439ce541f..90a57daa399 100644 --- a/backends/native/test/samples/record.py +++ b/backends/native/test/samples/record.py @@ -8,11 +8,12 @@ A package holds one method per case and dtype under one quantization scheme, and two sample sets over the same inputs: `golden`, the eager outputs, and -`precise`, the same math in float64 with weights dequantized and activations -unquantized, floating outputs stored as float32. Weight-only schemes add -`golden-int8_act`, the eager outputs with the same weights and int8 per-token -dynamic activations. Nothing in it depends on an engine or a device: consumers -judge an engine by its distance from `precise` relative to eager's. +`precise`, the same math in float64 with quantized weights decoded in float32, +whatever the method's dtype, and activations unquantized, floating outputs +stored as float32. Weight-only schemes add `golden-int8_act`, the eager outputs +with the same weights and int8 per-token dynamic activations. Nothing in it +depends on an engine or a device: consumers judge an engine by its distance +from `precise` relative to eager's. """ import copy @@ -93,7 +94,9 @@ def record( str(path), "precise", SampleSet( - precise, "float64, weights dequantized, activations unquantized", source + precise, + "float64, quantized weights decoded in float32, activations unquantized", + source, ), ) if scheme.weight_only: diff --git a/backends/native/test/samples/schemes.py b/backends/native/test/samples/schemes.py index af4183da9b1..386c24bf71f 100644 --- a/backends/native/test/samples/schemes.py +++ b/backends/native/test/samples/schemes.py @@ -14,11 +14,13 @@ import numpy as np import torch -from executorch.extension.llm.export.gguf import iter_gguf, QK_K +from executorch.extension.llm.export.gguf import ExportableGGUFTensor, iter_gguf, QK_K from gguf import GGML_QUANT_SIZES, GGMLQuantizationType, GGUFWriter from torchao.core.config import AOBaseConfig from torchao.quantization import ( + dequantize_affine, Int8DynamicActivationIntxWeightConfig, + IntxUnpackedToInt8Tensor, IntxWeightOnlyConfig, quantize_, ) @@ -48,7 +50,9 @@ def quantize(self, model: torch.nn.Module) -> None: pass def decode(self, value: torch.Tensor) -> torch.Tensor: - """A state tensor of a quantized model as a plain tensor.""" + """A state tensor of a quantized model as a plain tensor, with quantized + weights decoded in float32 rather than the method's dtype, which can + round them.""" return value @@ -88,7 +92,13 @@ def quantize(self, model: torch.nn.Module) -> None: quantize_(model, self.config) def decode(self, value: torch.Tensor) -> torch.Tensor: - return value.dequantize() if isinstance(value, TorchAOBaseTensor) else value + if isinstance(value, IntxUnpackedToInt8Tensor): + return dequantize_affine( + value.qdata, value.block_size, value.scale, value.zero_point, torch.int8 + ) + if isinstance(value, TorchAOBaseTensor): + raise TypeError(f"no float32 decode for {type(value).__name__}") + return value # fp16 super-block scales by byte offset in each block, kept in fp16's normal @@ -149,7 +159,9 @@ def quantize(self, model: torch.nn.Module) -> None: _load_gguf(model, self.ggml_type) def decode(self, value: torch.Tensor) -> torch.Tensor: - return value.dequantize() if isinstance(value, TorchAOBaseTensor) else value + if isinstance(value, ExportableGGUFTensor): + return value.dequantize(torch.float32) + return value @contextlib.contextmanager diff --git a/backends/native/test/samples/test_record.py b/backends/native/test/samples/test_record.py index 086f2a55d9b..1438f648162 100644 --- a/backends/native/test/samples/test_record.py +++ b/backends/native/test/samples/test_record.py @@ -4,6 +4,7 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. +import copy import tempfile import unittest from pathlib import Path @@ -13,8 +14,15 @@ import torch.nn.functional as F from executorch.backends.native.test.samples.cases import Case, draws, suite_cases from executorch.backends.native.test.samples.record import record -from executorch.backends.native.test.samples.schemes import Scheme, schemes +from executorch.backends.native.test.samples.schemes import ( + Gguf, + Scheme, + schemes, + Torchao, +) from executorch.exir.native import read_samples +from torchao.quantization import IntxWeightOnlyConfig, quantize_ +from torchao.quantization.granularity import PerGroup class RecordTest(unittest.TestCase): @@ -107,3 +115,43 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: record([case], Scheme(), path, dtypes=["f32"]) precise = read_samples(str(path))["precise"].samples self.assertEqual(precise[0].calls[0].outputs[0].dtype, torch.int64) + + def test_precise_decodes_gguf_weights_whatever_the_method_dtype(self) -> None: + # Halves are exact in every recorded dtype and the methods share their + # GGUF blocks, so their precise outputs must match. + x = torch.randint(-8, 9, (3, 256)) / 2 + model = torch.nn.Linear(256, 8, bias=False) + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "linear.ptn" + record([Case("linear", model, [(x,)])], Gguf("q6_k"), path) + samples = read_samples(str(path))["precise"].samples + outputs = {s.calls[0].method: s.calls[0].outputs[0] for s in samples} + for dtype in ("f16", "bf16"): + torch.testing.assert_close( + outputs[f"linear_{dtype}"], outputs["linear_f32"], rtol=0, atol=0 + ) + + def test_precise_decodes_intx_weights_in_float32(self) -> None: + # A code times its scale can need more bits than bf16 holds, so decoding + # in the method's dtype would round the weights. + x = torch.randint(-8, 9, (3, 64)) / 2 + model = torch.nn.Linear(64, 8, bias=False) + config = IntxWeightOnlyConfig(weight_dtype=torch.int4, granularity=PerGroup(32)) + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "linear.ptn" + record([Case("linear", model, [(x,)])], Torchao(config), path) + samples = read_samples(str(path))["precise"].samples + outputs = {s.calls[0].method: s.calls[0].outputs[0] for s in samples} + bf16 = copy.deepcopy(model).to(torch.bfloat16) + quantize_(bf16, config) + w = bf16.weight + codes = ( + w.qdata.double().unflatten(1, (-1, 32)) - w.zero_point.double()[..., None] + ) + weight = (codes * w.scale.double()[..., None]).flatten(1) + torch.testing.assert_close( + outputs["linear_bf16"], + torch.nn.functional.linear(x.double(), weight).float(), + rtol=0, + atol=0, + ) From 144eba81a2c72328a79c96b22f1c9677f029948d Mon Sep 17 00:00:00 2001 From: Digant Desai Date: Tue, 3 Nov 2026 06:24:29 -0800 Subject: [PATCH 122/128] [executorch][native] Cover negative, rounded and subnormal GGUF super-block scales in the op samples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pull Request resolved: https://github.com/pytorch/executorch/pull/24368 GGUF samples used fixed positive power-of-two super-block scales, so engines' decoders never saw negative, rounded or subnormal ones. A model's i-th `nn.Linear` now takes the i-th scale family, and a new `scale_families` case gives each family its own output. A zero-d family waits behind a TODO. With q4_k's d at 0 every weight is -dmin·m, so a token's 32 outputs share one activation-quantization error (correlation about 0.74) and `OutputCheck`'s bias standard error, which assumes independent outputs, comes out about 4.9 times too small; with q6_k's d at 0 every weight is zero and the outputs are just the bias. Authored with Claude Code. @bypass-github-export-checks @bypass-github-pytorch-ci-checks @bypass-github-executorch-ci-checks ghstack-source-id: 463657756 @exported-using-ghexport Differential Revision: [D128254896](https://our.internmc.facebook.com/intern/diff/D128254896/) --- backends/native/test/samples/linear.py | 18 ++++++++ backends/native/test/samples/schemes.py | 46 +++++++++++++++++---- backends/native/test/samples/test_record.py | 11 +++++ 3 files changed, 66 insertions(+), 9 deletions(-) diff --git a/backends/native/test/samples/linear.py b/backends/native/test/samples/linear.py index 9ee902e681c..9beea6408fa 100644 --- a/backends/native/test/samples/linear.py +++ b/backends/native/test/samples/linear.py @@ -14,6 +14,7 @@ facto_cases, suite_cases, ) +from executorch.backends.native.test.samples.schemes import GGUF_SUPER_SCALES from executorch.backends.test.suite.operators import test_linear from executorch.extension.llm.export.gguf import QK_K from facto.inputgen.argument.type import ArgType @@ -78,6 +79,17 @@ def _facto_case(name: str, x, weight, bias) -> Case: return Case(name, model, [(x,)]) +class _Parallel(torch.nn.Module): + """nn.Linears on one input, each giving its own output.""" + + def __init__(self, *linears: torch.nn.Linear) -> None: + super().__init__() + self.linears = torch.nn.ModuleList(linears) + + def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, ...]: + return tuple(m(x) for m in self.linears) + + def cases() -> list[Case]: suite = suite_cases(test_linear, samples=4) torch.manual_seed(0) @@ -100,6 +112,12 @@ def cases() -> list[Case]: ), Case("outlier_channel", torch.nn.Linear(256, 128), outlier), Case("zero_row", torch.nn.Linear(256, 64), zero_row), + # One output per family of GGUF super-block scales. + Case( + "scale_families", + _Parallel(*(torch.nn.Linear(256, 32) for _ in GGUF_SUPER_SCALES["q4_k"])), + draws(2, (4, 256)), + ), # FACTO varies one argument at a time, so only every fifth tuple is kept, # plus those whose in_features fit GGUF blocks. Its values lie on a 1/8 # grid, which makes float32 math exact: these cases cover shapes more diff --git a/backends/native/test/samples/schemes.py b/backends/native/test/samples/schemes.py index 386c24bf71f..c0c2f3d1fe8 100644 --- a/backends/native/test/samples/schemes.py +++ b/backends/native/test/samples/schemes.py @@ -101,18 +101,40 @@ def decode(self, value: torch.Tensor) -> torch.Tensor: return value -# fp16 super-block scales by byte offset in each block, kept in fp16's normal -# range; q4_k's dmin of 8 d centers random weights near zero. -_GGUF_SUPER_SCALES = {"q4_k": {0: 2**-12, 2: 2**-9}, "q6_k": {208: 2**-12}} +# Families of fp16 super-block scales by byte offset in each block: powers of +# two, where q4_k's dmin of 8 d centers random weights near zero, then negative, +# rounded and subnormal ones. A model's i-th nn.Linear takes the i-th family, so +# one nn.Linear per family isolates each in its own output. +# TODO: Add a zero-d family once OutputCheck's bias standard error stops +# assuming independent outputs. +GGUF_SUPER_SCALES = { + "q4_k": [ + {0: 2**-12, 2: 2**-9}, + {0: -(2**-12), 2: -(2**-9)}, + {0: 3.1e-4, 2: 2.3e-3}, + {0: 3 * 2**-21, 2: 3 * 2**-18}, + ], + "q6_k": [ + {208: 2**-12}, + {208: -(2**-12)}, + {208: 3.1e-4}, + {208: 3 * 2**-21}, + ], +} def _random_blocks( - ggml_type: str, rows: int, cols: int, rng: np.random.Generator + ggml_type: str, + rows: int, + cols: int, + scales: dict[int, float], + rng: np.random.Generator, ) -> np.ndarray: - """Valid `ggml_type` blocks for a [rows, cols] weight from random bytes.""" + """Valid `ggml_type` blocks for a [rows, cols] weight from random bytes and + the given super-block scales.""" _, block_bytes = GGML_QUANT_SIZES[GGMLQuantizationType[ggml_type.upper()]] raw = rng.integers(0, 256, (rows, cols // QK_K, block_bytes), dtype=np.uint8) - for offset, scale in _GGUF_SUPER_SCALES[ggml_type].items(): + for offset, scale in scales.items(): raw[..., offset : offset + 2] = np.array([scale], np.float16).view(np.uint8) return raw.reshape(rows, -1) @@ -125,10 +147,15 @@ def _load_gguf(model: torch.nn.Module, ggml_type: str) -> None: with tempfile.TemporaryDirectory() as tmp: path = f"{tmp}/weights.gguf" writer = GGUFWriter(path, arch="linear") - for i, m in enumerate(linears): + families = GGUF_SUPER_SCALES[ggml_type] + if len(linears) > len(families): + raise ValueError( + f"{len(linears)} nn.Linears, {len(families)} {ggml_type} scale families" + ) + for i, (m, scales) in enumerate(zip(linears, families)): writer.add_tensor( f"linear.{i}.weight", - _random_blocks(ggml_type, *m.weight.shape, rng), + _random_blocks(ggml_type, *m.weight.shape, scales, rng), raw_dtype=GGMLQuantizationType[ggml_type.upper()], ) writer.write_header_to_file() @@ -153,7 +180,8 @@ def weight_only(self) -> bool: return True def fits(self, model: torch.nn.Module) -> bool: - return _fits(model, QK_K) + linears = sum(isinstance(m, torch.nn.Linear) for m in model.modules()) + return _fits(model, QK_K) and linears <= len(GGUF_SUPER_SCALES[self.ggml_type]) def quantize(self, model: torch.nn.Module) -> None: _load_gguf(model, self.ggml_type) diff --git a/backends/native/test/samples/test_record.py b/backends/native/test/samples/test_record.py index 1438f648162..fd5d28d3b2f 100644 --- a/backends/native/test/samples/test_record.py +++ b/backends/native/test/samples/test_record.py @@ -16,6 +16,7 @@ from executorch.backends.native.test.samples.record import record from executorch.backends.native.test.samples.schemes import ( Gguf, + GGUF_SUPER_SCALES, Scheme, schemes, Torchao, @@ -131,6 +132,16 @@ def test_precise_decodes_gguf_weights_whatever_the_method_dtype(self) -> None: outputs[f"linear_{dtype}"], outputs["linear_f32"], rtol=0, atol=0 ) + def test_gguf_fits_no_more_linears_than_scale_families(self) -> None: + def linears(count: int) -> torch.nn.Module: + return torch.nn.Sequential( + *(torch.nn.Linear(256, 256) for _ in range(count)) + ) + + families = len(GGUF_SUPER_SCALES["q4_k"]) + self.assertTrue(Gguf("q4_k").fits(linears(families))) + self.assertFalse(Gguf("q4_k").fits(linears(families + 1))) + def test_precise_decodes_intx_weights_in_float32(self) -> None: # A code times its scale can need more bits than bf16 holds, so decoding # in the method's dtype would round the weights. From 515fc2efde581f0f4c13b97969359d8c91efb6e7 Mon Sep 17 00:00:00 2001 From: Digant Desai Date: Tue, 3 Nov 2026 06:24:30 -0800 Subject: [PATCH 123/128] [executorch][vulkan] Accept 1-D activations in 8da4w linear Pull Request resolved: https://github.com/pytorch/executorch/pull/24369 `linear_dq8ca_q4gsw` failed on rank-1 activations: `is_2d_matrix` rejected them, and `get_quantized_input_num_blocks` read the size at `ndim - 2`. Both now treat a 1-D tensor as a single row. Authored with Claude Code. @bypass-github-export-checks @bypass-github-pytorch-ci-checks @bypass-github-executorch-ci-checks ghstack-source-id: 463657751 @exported-using-ghexport Differential Revision: [D128254882](https://our.internmc.facebook.com/intern/diff/D128254882/) --- .../vulkan/runtime/graph/ComputeGraph.cpp | 4 +- backends/vulkan/runtime/graph/ComputeGraph.h | 6 +- .../graph/ops/impl/QuantizeDequantize.cpp | 7 +- .../vulkan/test/vulkan_compute_api_test.cpp | 78 +++++++++++++++++++ 4 files changed, 86 insertions(+), 9 deletions(-) diff --git a/backends/vulkan/runtime/graph/ComputeGraph.cpp b/backends/vulkan/runtime/graph/ComputeGraph.cpp index a6b3c4f6347..e3997b26988 100644 --- a/backends/vulkan/runtime/graph/ComputeGraph.cpp +++ b/backends/vulkan/runtime/graph/ComputeGraph.cpp @@ -426,10 +426,10 @@ bool ComputeGraph::is_standard_channels_packed_texture_tensor( bool ComputeGraph::is_2d_matrix(const ValueRef idx) const { std::vector sizes = sizes_of(idx); const size_t ndim = sizes.size(); - if (sizes.size() < 2) { + if (ndim == 0) { return false; } - if (sizes.size() == 2) { + if (ndim <= 2) { return true; } diff --git a/backends/vulkan/runtime/graph/ComputeGraph.h b/backends/vulkan/runtime/graph/ComputeGraph.h index 6b78d8b50fb..eb0a0e168d2 100644 --- a/backends/vulkan/runtime/graph/ComputeGraph.h +++ b/backends/vulkan/runtime/graph/ComputeGraph.h @@ -440,9 +440,9 @@ class ComputeGraph final { bool is_standard_channels_packed_texture_tensor(const ValueRef idx) const; /* - * Checks that the value at `idx` is either a 2D tensor, or if the tensor has - * more than 2 dims, the outermost dims have size of 1, i.e. can be squeezed - * to be a 2D tensor. + * Checks that the value at `idx` is a 1D tensor (a single row), a 2D tensor, + * or a tensor whose outermost dims have size of 1, i.e. can be squeezed to + * be a 2D tensor. */ bool is_2d_matrix(const ValueRef idx) const; diff --git a/backends/vulkan/runtime/graph/ops/impl/QuantizeDequantize.cpp b/backends/vulkan/runtime/graph/ops/impl/QuantizeDequantize.cpp index 7cf03d98a94..5834f2551e1 100644 --- a/backends/vulkan/runtime/graph/ops/impl/QuantizeDequantize.cpp +++ b/backends/vulkan/runtime/graph/ops/impl/QuantizeDequantize.cpp @@ -32,11 +32,10 @@ bool is_gemv(ComputeGraph* graph, const ValueRef& fp_input) { std::tuple get_quantized_input_num_blocks( ComputeGraph& graph, const ValueRef input) { - std::vector input_sizes = graph.sizes_of(input); - const int64_t ndim = graph.dim_of(input); + const std::vector input_sizes = graph.sizes_of(input); - const int64_t M = input_sizes.at(ndim - 2); - const int64_t K = input_sizes.at(ndim - 1); + const int64_t M = utils::val_at(-2, input_sizes); + const int64_t K = utils::val_at(-1, input_sizes); const int64_t num_blocks_M = utils::div_up(M, int64_t(4)); const int64_t num_blocks_K = utils::div_up(K, int64_t(4)); diff --git a/backends/vulkan/test/vulkan_compute_api_test.cpp b/backends/vulkan/test/vulkan_compute_api_test.cpp index 0374505d0bb..b087b0e4baa 100644 --- a/backends/vulkan/test/vulkan_compute_api_test.cpp +++ b/backends/vulkan/test/vulkan_compute_api_test.cpp @@ -2465,6 +2465,84 @@ TEST(VulkanComputeGraphTest, choose_qparams_handles_dynamic_row_counts) { } } +TEST(VulkanComputeGraphTest, choose_qparams_affine_handles_1d_input) { + constexpr int64_t kK = 128; + + ComputeGraph graph(GraphConfig{}); + const IOValueRef input = + graph.add_input_tensor({kK}, vkapi::kFloat, utils::kBuffer); + const ValueRef scales = graph.add_tensor( + {1}, vkapi::kFloat, utils::kTexture3D, utils::kWidthPacked); + const ValueRef zero_points = graph.add_tensor( + {1}, vkapi::kChar, utils::kTexture3D, utils::kWidthPacked); + + VK_GET_OP_FN("torchao.choose_qparams_affine.default") + (graph, + {input.value, + graph.add_none(), // mapping_type + graph.add_scalar_list({kK}), + graph.add_none(), // target_dtype + graph.add_scalar(-128), + graph.add_scalar(127), + graph.add_none(), // eps + graph.add_none(), // scale_dtype + graph.add_none(), // zero_point_dtype + graph.add_value_list({scales, zero_points})}); + + const ValueRef scales_staging = graph.set_output_tensor(scales); + const ValueRef zero_points_staging = graph.set_output_tensor(zero_points); + + graph.prepare(); + graph.prepack(); + + // The row's maximum is its last element, so the whole row must be reduced. + std::vector input_data(kK); + for (int64_t k = 0; k < kK; ++k) { + input_data[k] = 0.5f * float(k); + } + graph.maybe_cast_and_copy_into_staging( + input.staging, input_data.data(), input_data.size(), vkapi::kFloat); + + graph.execute(); + + float scale = 0; + int8_t zero_point = 0; + graph.maybe_cast_and_copy_from_staging( + scales_staging, &scale, 1, vkapi::kFloat); + graph.maybe_cast_and_copy_from_staging( + zero_points_staging, &zero_point, 1, vkapi::kChar); + EXPECT_NEAR(scale, input_data.back() / 255.0f, 1e-6f); + EXPECT_EQ(zero_point, -128); +} + +TEST(VulkanComputeGraphTest, quantize_and_pack_accepts_1d_input) { + constexpr int64_t kK = 128; + + ComputeGraph graph(GraphConfig{}); + const IOValueRef input = + graph.add_input_tensor({kK}, vkapi::kFloat, utils::kBuffer); + const ValueRef scales = graph.add_tensor( + {1}, vkapi::kFloat, utils::kTexture3D, utils::kWidthPacked); + const ValueRef zero_points = graph.add_tensor( + {1}, vkapi::kChar, utils::kTexture3D, utils::kWidthPacked); + const ValueRef packed_input = graph.add_tensor( + {1, kK}, vkapi::kInt8x4, utils::kBuffer, utils::kPackedInt8_4H4W); + const ValueRef input_sums = + graph.add_tensor({4}, vkapi::kInt, utils::kBuffer, utils::kWidthPacked); + const QuantizationConfig input_quant_config( + 8, kPerChannel, {1, kK}, false, true); + + EXPECT_NO_THROW(add_quantize_and_pack_4h4w_with_group_sums_node( + graph, + input_quant_config, + input.value, + input_sums, + scales, + zero_points, + packed_input, + graph.add_scalar(kK))); +} + void test_quantize_and_pack_handles_dynamic_row_counts( const int64_t group_size_value, const utils::uvec3& expected_local_wg_size) { From 2b2ec0e47b213f29539923ebd9bb78dc944edc8c Mon Sep 17 00:00:00 2001 From: Digant Desai Date: Tue, 3 Nov 2026 06:24:31 -0800 Subject: [PATCH 124/128] [executorch][vulkan] Fix dq8ca q4gsw weight sums stride when N % 8 is 1 to 4 Pull Request resolved: https://github.com/pytorch/executorch/pull/24370 `linear_dq8ca_q4gsw_tiled` strode each group's weight sums by `div_up(N, 4)` texels, but the sums are laid out `{groups, align_up(N, 8)}`, as both the ET-VK AOT pass (`compute_per_group_sums`) and Native's `FuseQuantizedLinear` write them. When `div_up(N, 4)` is odd, every group after the first read another row's sums; the shader now strides by `2 * div_up(N, 8)`, which equals the old stride for every other N. Authored with Claude Code. @bypass-github-export-checks @bypass-github-pytorch-ci-checks @bypass-github-executorch-ci-checks ghstack-source-id: 463657747 @exported-using-ghexport Differential Revision: [D128254883](https://our.internmc.facebook.com/intern/diff/D128254883/) --- .../gemm/linear/qw/dq8ca_q4gsw/linear_dq8ca_q4gsw_tiled.glsl | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backends/vulkan/runtime/graph/ops/glsl/gemm/linear/qw/dq8ca_q4gsw/linear_dq8ca_q4gsw_tiled.glsl b/backends/vulkan/runtime/graph/ops/glsl/gemm/linear/qw/dq8ca_q4gsw/linear_dq8ca_q4gsw_tiled.glsl index 5acab8e9a12..d29167e5c9a 100644 --- a/backends/vulkan/runtime/graph/ops/glsl/gemm/linear/qw/dq8ca_q4gsw/linear_dq8ca_q4gsw_tiled.glsl +++ b/backends/vulkan/runtime/graph/ops/glsl/gemm/linear/qw/dq8ca_q4gsw/linear_dq8ca_q4gsw_tiled.glsl @@ -130,7 +130,8 @@ void main() { } load_weight_scales_tile_for_group(weight_scales_tile, n4, group_i, N4); - load_weight_sums_tile_for_group(weight_sums_tile, n4, group_i, N4); + // Weight sums rows are padded to a multiple of 8. + load_weight_sums_tile_for_group(weight_sums_tile, n4, group_i, mul_2(N8)); load_int8_input_sums_tile_for_group(int8_input_sums_tile, m4, group_i, M4); accumulate_out_tile_with_int_accum_from_int4_weights( From cfcee439c4e1b4a8a7384be1dc6d9c7f505bfad6 Mon Sep 17 00:00:00 2001 From: Digant Desai Date: Tue, 3 Nov 2026 06:24:32 -0800 Subject: [PATCH 125/128] [executorch][native-vk] Note the int4 weight-only N % 4 and rank-4 linear gaps Pull Request resolved: https://github.com/pytorch/executorch/pull/24371 The Vulkan engine leaves int4 weight-only linears unlowered when N isn't a multiple of 4, so op-sample cases with N of 1 and 43 fail. It also computes a linear on a rank-4 input wrong, while rank-3 inputs pass. `TODO.md` records both, with the fix for the first and where to look for the second. Authored with Claude Code. @bypass-github-export-checks @bypass-github-pytorch-ci-checks @bypass-github-executorch-ci-checks ghstack-source-id: 463657741 @exported-using-ghexport Differential Revision: [D128254884](https://our.internmc.facebook.com/intern/diff/D128254884/) --- backends/native/runtime/vulkan/TODO.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/backends/native/runtime/vulkan/TODO.md b/backends/native/runtime/vulkan/TODO.md index 2b6b1de54df..f4c8d3bd7ad 100644 --- a/backends/native/runtime/vulkan/TODO.md +++ b/backends/native/runtime/vulkan/TODO.md @@ -14,3 +14,23 @@ HuggingFace Llama 3.2 1B 8da4w, 2K export: peak GPU memory is 2794 MiB, against 2265 MiB with buffer SDPA disabled and 2263 MiB for the ET-VK `.pte`. The +528 MiB comes from enabling buffer SDPA. Give these temporaries lifetimes in the memory plan, measure again, and attribute whatever remains. + +## Lower int4 weight-only linear for any N + +`FuseQuantizedLinear` leaves an int4 weight-only linear unlowered when N is not +a multiple of 4, to match `VK_CHECK_COND(N % 4 == 0)` in ET-VK's +`prepack_q4_w_4x8_nc_buffer`, and the engine then rejects the quantized weight. +Round N up to a multiple of 4 there (the pack shader already fills n >= N with +zeros) and mask the partial last tile on store. Until then Vulkan fails the op +sample tests `linear_int4wo_*.linear_feature_sizes_2` and +`linear_int4wo_*.linear_unbatched_0`, where N is 1 and 43. + +## Rank-4 linear input + +Vulkan returns wrong outputs for the op sample test +`linear_float.linear_leading_batch_1`, an `nn.Linear(67, 43)` on a +(2, 4, 8, 67) input: in f32 the fitted rel_bias is -0.48, and f16 returns +values up to 6.1e4. Both are consistent with only one of the two outer batches +being written. The rank-3 inputs (4, 8, 67) and (2, 7, 256) pass. Find where +the outer dimension is lost: the engine's linear lowering, or ET-VK's linear on +a rank-4 tensor. From 69773cc1eb3e83c301ea96c8e33ff6c234851383 Mon Sep 17 00:00:00 2001 From: Digant Desai Date: Tue, 3 Nov 2026 06:24:32 -0800 Subject: [PATCH 126/128] [executorch][vulkan] Round f16 image stores to nearest even Pull Request resolved: https://github.com/pytorch/executorch/pull/24372 Vulkan lets the device choose how stores to rgba16f images round, and devices that round toward zero bias f16 outputs; shader float controls don't govern that conversion. `gen_vulkan_spv.py` now wraps `imageStore` on writable rgba16f images in a round-to-nearest-even helper, and six shaders that declared such images by hand, two of them test shaders, now use the codegen. On devices that round toward zero, stores of 65520 and above now give inf, as the CPU conversion does, instead of 65504. The four texture shaders that already call `round_to_half_rte` on their outputs (`binary_scalar_texture`, `full_texture`, `reduce`, `scalar_tensor`) now round twice, with identical results; removing those calls is a follow-up. `aten._to_copy` from f32 to f16 textures is a `vkCmdBlitImage`, which the codegen doesn't reach, so `test_to_copy` keeps its one-ulp tolerance. Authored with Claude Code. @bypass-github-export-checks @bypass-github-pytorch-ci-checks @bypass-github-executorch-ci-checks ghstack-source-id: 463657749 @exported-using-ghexport Differential Revision: [D128254886](https://our.internmc.facebook.com/intern/diff/D128254886/) --- backends/vulkan/runtime/gen_vulkan_spv.py | 37 +++++++++++ .../conv2d_dw_prepack_weights.glsl | 2 +- .../pack_weights/conv2d_prepack_weights.glsl | 2 +- .../conv_transpose2d_prepack_weights.glsl | 2 +- .../graph/ops/glsl/repeat_channel.glsl | 2 +- .../glsl/binary_op_nobroadcast__test.glsl | 4 +- .../vulkan/test/glsl/fill_texture__test.glsl | 2 +- .../test/spv_codegen/test_gen_vulkan_spv.py | 16 +++++ .../vulkan/test/vulkan_compute_api_test.cpp | 65 +++++++++++++++++++ 9 files changed, 124 insertions(+), 8 deletions(-) diff --git a/backends/vulkan/runtime/gen_vulkan_spv.py b/backends/vulkan/runtime/gen_vulkan_spv.py index 5701248266e..76999cb0995 100644 --- a/backends/vulkan/runtime/gen_vulkan_spv.py +++ b/backends/vulkan/runtime/gen_vulkan_spv.py @@ -355,6 +355,38 @@ def layout_declare_buffer( return out_str +# Vulkan leaves the rounding of a store to an rgba16f image to the device, and +# some devices round toward zero. Rounding to the nearest f16, ties to even, in +# the shader makes the store exact. Integer texels are stored as is, but stores +# to rgba32f images would be rounded too, so a shader may not write both. +ROUND_F16_IMAGE_STORES = """ +#ifdef WRITES_RGBA32F_IMAGE +#error "A shader cannot write both rgba16f and rgba32f images" +#endif +#ifndef WRITES_RGBA16F_IMAGE +#define WRITES_RGBA16F_IMAGE +vec4 round_to_f16(const vec4 v) { + const uvec4 u = floatBitsToUint(v); + // Keep 10 mantissa bits: add just under half an f16 ulp, plus one when the + // kept lowest bit is odd so ties go to even, then clear the 13 dropped bits. + vec4 r = uintBitsToFloat((u + 0xFFFu + ((u >> 13) & 1u)) & ~0x1FFFu); + // Below the smallest normal f16, round to a multiple of 2^-24. + r = mix(r, roundEven(v * 16777216.0) * 5.9604644775390625e-8, lessThan(abs(v), vec4(6.103515625e-5))); + r = mix(r, uintBitsToFloat((u & 0x80000000u) | 0x7F800000u), greaterThan(abs(r), vec4(65504.0))); + return mix(r, v, isnan(v)); +} +ivec4 round_to_f16(const ivec4 v) { return v; } +uvec4 round_to_f16(const uvec4 v) { return v; } +#define imageStore(image, pos, texel) imageStore(image, pos, round_to_f16(texel)) +#endif""" + +WRITES_RGBA32F_IMAGE = """ +#ifdef WRITES_RGBA16F_IMAGE +#error "A shader cannot write both rgba16f and rgba32f images" +#endif +#define WRITES_RGBA32F_IMAGE""" + + def layout_declare_image( slot: Union[int, List[int]], access_type: str, @@ -367,6 +399,11 @@ def layout_declare_image( image_type = TYPE_MAPPINGS["IMAGE_T"][image_ndim][dtype] ret_str = f"layout(set = 0, binding = {get_slot_val(slot)}, {image_format}) uniform {precision} restrict {get_access_qualifier(access_type)} {image_type} {var_name};" + if access_type.lower() != "r": + if image_format == "rgba16f": + ret_str += ROUND_F16_IMAGE_STORES + elif image_format == "rgba32f": + ret_str += WRITES_RGBA32F_IMAGE if isinstance(slot, list): slot[0] = slot[0] + 1 diff --git a/backends/vulkan/runtime/graph/ops/glsl/convolution/conv2d/pack_weights/conv2d_dw_prepack_weights.glsl b/backends/vulkan/runtime/graph/ops/glsl/convolution/conv2d/pack_weights/conv2d_dw_prepack_weights.glsl index 4707bcf9f9c..4a16ee05d42 100644 --- a/backends/vulkan/runtime/graph/ops/glsl/convolution/conv2d/pack_weights/conv2d_dw_prepack_weights.glsl +++ b/backends/vulkan/runtime/graph/ops/glsl/convolution/conv2d/pack_weights/conv2d_dw_prepack_weights.glsl @@ -21,7 +21,7 @@ ${define_required_extensions("buffer", BUF_DTYPE)} layout(std430) buffer; -layout(set = 0, binding = 0, ${IMAGE_FORMAT[DTYPE]}) uniform PRECISION restrict writeonly ${IMAGE_T[2][DTYPE]} image_out; +${layout_declare_tensor(0, "w", "image_out", DTYPE, "texture2d")} layout(set = 0, binding = 1) buffer PRECISION restrict readonly Buffer { BUF_T buffer_in[]; }; diff --git a/backends/vulkan/runtime/graph/ops/glsl/convolution/conv2d/pack_weights/conv2d_prepack_weights.glsl b/backends/vulkan/runtime/graph/ops/glsl/convolution/conv2d/pack_weights/conv2d_prepack_weights.glsl index b5bec8fca0b..95c8cc3c4ee 100644 --- a/backends/vulkan/runtime/graph/ops/glsl/convolution/conv2d/pack_weights/conv2d_prepack_weights.glsl +++ b/backends/vulkan/runtime/graph/ops/glsl/convolution/conv2d/pack_weights/conv2d_prepack_weights.glsl @@ -29,7 +29,7 @@ $if OUTPUT_BUFFER: f16vec4 buffer_out[]; }; $else: - layout(set = 0, binding = 0, ${IMAGE_FORMAT[DTYPE]}) uniform PRECISION restrict writeonly ${IMAGE_T[2][DTYPE]} image_out; + ${layout_declare_tensor(0, "w", "image_out", DTYPE, "texture2d")} layout(set = 0, binding = 1) buffer PRECISION restrict readonly Buffer { BUF_T buffer_in[]; }; diff --git a/backends/vulkan/runtime/graph/ops/glsl/convolution/conv2d/transposed/pack_weights/conv_transpose2d_prepack_weights.glsl b/backends/vulkan/runtime/graph/ops/glsl/convolution/conv2d/transposed/pack_weights/conv_transpose2d_prepack_weights.glsl index 65b998a9c5b..4a47892375e 100644 --- a/backends/vulkan/runtime/graph/ops/glsl/convolution/conv2d/transposed/pack_weights/conv_transpose2d_prepack_weights.glsl +++ b/backends/vulkan/runtime/graph/ops/glsl/convolution/conv2d/transposed/pack_weights/conv_transpose2d_prepack_weights.glsl @@ -21,7 +21,7 @@ ${define_required_extensions("buffer", BUF_DTYPE)} layout(std430) buffer; -layout(set = 0, binding = 0, ${IMAGE_FORMAT[DTYPE]}) uniform PRECISION restrict writeonly ${IMAGE_T[2][DTYPE]} image_out; +${layout_declare_tensor(0, "w", "image_out", DTYPE, "texture2d")} layout(set = 0, binding = 1) buffer PRECISION restrict readonly Buffer { BUF_T buffer_in[]; }; diff --git a/backends/vulkan/runtime/graph/ops/glsl/repeat_channel.glsl b/backends/vulkan/runtime/graph/ops/glsl/repeat_channel.glsl index 42c7f86aea8..96e9f7aac4c 100644 --- a/backends/vulkan/runtime/graph/ops/glsl/repeat_channel.glsl +++ b/backends/vulkan/runtime/graph/ops/glsl/repeat_channel.glsl @@ -16,7 +16,7 @@ layout(std430) buffer; #include "indexing_utils.h" -layout(set = 0, binding = 0, ${IMAGE_FORMAT[DTYPE]}) uniform PRECISION restrict writeonly ${IMAGE_T[NDIM][DTYPE]} image_out; +${layout_declare_image(0, "w", "image_out", DTYPE, image_ndim=NDIM)} layout(set = 0, binding = 1) uniform PRECISION sampler3D image_in; layout(set = 0, binding = 2) uniform PRECISION restrict RepeatArgs { diff --git a/backends/vulkan/test/glsl/binary_op_nobroadcast__test.glsl b/backends/vulkan/test/glsl/binary_op_nobroadcast__test.glsl index 7f72ac58972..5fcd1513c4c 100644 --- a/backends/vulkan/test/glsl/binary_op_nobroadcast__test.glsl +++ b/backends/vulkan/test/glsl/binary_op_nobroadcast__test.glsl @@ -14,9 +14,7 @@ layout(std430) buffer; -// clang-format off -layout(set = 0, binding = 0, ${IMAGE_FORMAT[DTYPE]}) uniform PRECISION restrict writeonly image3D image_out; -// clang-format on +${layout_declare_image(0, "w", "image_out", DTYPE)} layout(set = 0, binding = 1) uniform PRECISION sampler3D image_in; layout(set = 0, binding = 2) uniform PRECISION sampler3D image_other; diff --git a/backends/vulkan/test/glsl/fill_texture__test.glsl b/backends/vulkan/test/glsl/fill_texture__test.glsl index 76c630de55e..637c12fbb51 100644 --- a/backends/vulkan/test/glsl/fill_texture__test.glsl +++ b/backends/vulkan/test/glsl/fill_texture__test.glsl @@ -12,7 +12,7 @@ layout(std430) buffer; -layout(set = 0, binding = 0, ${IMAGE_FORMAT[DTYPE]}) uniform PRECISION restrict writeonly ${IMAGE_T[NDIM][DTYPE]} uOutput; +${layout_declare_image(0, "w", "uOutput", DTYPE, image_ndim=NDIM)} layout(set = 0, binding = 1) uniform PRECISION restrict Block { ivec3 size; int fill; diff --git a/backends/vulkan/test/spv_codegen/test_gen_vulkan_spv.py b/backends/vulkan/test/spv_codegen/test_gen_vulkan_spv.py index aef464dbf38..e569d6fe27d 100644 --- a/backends/vulkan/test/spv_codegen/test_gen_vulkan_spv.py +++ b/backends/vulkan/test/spv_codegen/test_gen_vulkan_spv.py @@ -13,6 +13,22 @@ from runtime import gen_vulkan_spv +class TestF16ImageStores(unittest.TestCase): + def test_writable_rgba16f_images_round_stores(self) -> None: + for access in ("w", "rw"): + with self.subTest(access=access): + declaration = gen_vulkan_spv.layout_declare_image( + 0, access, "t_out", "half" + ) + self.assertIn("round_to_f16(texel)", declaration) + + def test_other_images_store_unchanged(self) -> None: + for access, dtype in (("r", "half"), ("w", "float"), ("w", "int32")): + with self.subTest(access=access, dtype=dtype): + declaration = gen_vulkan_spv.layout_declare_image(0, access, "t", dtype) + self.assertNotIn("round_to_f16", declaration) + + class TestNestedShaderSources(unittest.TestCase): def _write_fake_compiler( self, directory: Path, assertions: str = "" diff --git a/backends/vulkan/test/vulkan_compute_api_test.cpp b/backends/vulkan/test/vulkan_compute_api_test.cpp index b087b0e4baa..7f4ef389040 100644 --- a/backends/vulkan/test/vulkan_compute_api_test.cpp +++ b/backends/vulkan/test/vulkan_compute_api_test.cpp @@ -1202,6 +1202,71 @@ TEST_F(VulkanComputeAPITest, spec_var_shader_test) { } } +TEST_F(VulkanComputeAPITest, half_texture_store_rounds_to_nearest_even) { + // Ties, their neighbors, f16 subnormals and values at and past the largest + // f16, where rounding toward zero differs from rounding to nearest even. + const std::vector values = { + 1.0f + 0x1p-11f, + 1.0f + 0x3p-11f, + -(1.0f + 0x3p-11f), + std::nextafter(1.0f + 0x1p-11f, 0.0f), + std::nextafter(1.0f + 0x1p-11f, 2.0f), + 0x1p-25f, + 0x3p-25f, + std::nextafter(0x1p-25f, 0.0f), + 0x1.ffcp-15f, + -0x1.0p-24f, + 65504.0f, + 65519.0f, + 65520.0f, + -65520.0f, + 1e6f, + -1e6f, + }; + + struct Params final { + utils::ivec3 size; + int32_t fill; + utils::vec4 values; + }; + + for (size_t i = 0; i < values.size(); i += 4) { + vTensor a( + context(), + {4, 1, 1}, + vkapi::kHalf, + utils::kTexture3D, + utils::kChannelsPacked); + Params block{ + {1, 1, 1}, + 0, + {values[i], values[i + 1], values[i + 2], values[i + 3]}, + }; + ParamsBuffer params(context(), block); + + vkapi::PipelineBarrier pipeline_barrier{}; + context()->submit_compute_job( + VK_KERNEL(fill_texture__test_half), + pipeline_barrier, + GlobalWorkGrid({1u, 1u, 1u}, kExplicitWorkGrid), + LocalWorkGroup(1u, 1u, 1u), + {}, + VK_NULL_HANDLE, + 0, + a.image( + pipeline_barrier, + vkapi::PipelineStage::COMPUTE, + vkapi::MemoryAccessType::WRITE), + params.buffer()); + + const std::vector out = extract_vtensor(a); + for (size_t j = 0; j < 4; ++j) { + EXPECT_EQ(out[j], float(executorch::aten::Half(values[i + j]))) + << "input " << values[i + j]; + } + } +} + TEST_F(VulkanComputeAPITest, update_params_between_submit) { context()->set_cmd(/*reusable = */ true); std::vector sizes = {4, 4, 2}; From 3b3c9f48012fd90410b833a270faf189fec4793c Mon Sep 17 00:00:00 2001 From: Digant Desai Date: Tue, 3 Nov 2026 06:24:33 -0800 Subject: [PATCH 127/128] [executorch][native] Run the ggml CPU act_quant sample test in CI Pull Request resolved: https://github.com/pytorch/executorch/pull/24373 `ggml_sample_test_act_quant` (CPU) is the one sample test that passes every package, so it leaves the manual set and runs in internal CI. The OSS ggml job now runs it as `native_ggml_sample_test_act_quant`, along with `native_output_check_test`. The rest stay manual with a TODO: plain `ggml_sample_test` fails the weight-only packages by design, and the Vulkan, CUDA and HIP ones have gaps. The OSS ggml job installs `gguf` for the op sample generator, and the workflow also runs on changes to FACTO and `test_linear.py`, which the samples draw from. Authored with Claude Code. @bypass-github-export-checks @bypass-github-pytorch-ci-checks @bypass-github-executorch-ci-checks ghstack-source-id: 463657737 @exported-using-ghexport Differential Revision: [D128668496](https://our.internmc.facebook.com/intern/diff/D128668496/) --- .ci/scripts/test-native.sh | 5 +++-- .github/workflows/test-native.yml | 9 ++++++--- backends/native/test/CMakeLists.txt | 6 +++--- backends/native/test/runtime/ggml/BUCK | 4 ++-- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/.ci/scripts/test-native.sh b/.ci/scripts/test-native.sh index 98139a453e2..fadbfe75840 100644 --- a/.ci/scripts/test-native.sh +++ b/.ci/scripts/test-native.sh @@ -36,8 +36,9 @@ case "${component}" in -DEXECUTORCH_BUILD_NATIVE_VISION_RUNNER=ON -DGGML_NATIVE=OFF ) - targets+=(run_hf_llm_ggml native_ggml_engine_test native_llm_runner_utils_test run_classification_ggml_cpu) - test_regex='^native_(ggml_engine|llm_runner_utils)_test$' + # TODO: run the other sample tests once they pass. + targets+=(run_hf_llm_ggml native_ggml_engine_test native_llm_runner_utils_test run_classification_ggml_cpu native_output_check_test native_ggml_sample_test) + test_regex='^native_(ggml_engine|llm_runner_utils|output_check)_test$|^native_ggml_sample_test_act_quant$' ;; vulkan) cmake_args+=( diff --git a/.github/workflows/test-native.yml b/.github/workflows/test-native.yml index c7a7aae2f0c..71d051f1679 100644 --- a/.github/workflows/test-native.yml +++ b/.github/workflows/test-native.yml @@ -17,6 +17,8 @@ on: - backends/cpu/CMakeLists.txt - backends/cpu/runtime/** - backends/native/** + - backends/cadence/utils/FACTO + - backends/test/suite/operators/test_linear.py - backends/vulkan/CMakeLists.txt - backends/vulkan/cmake/** - backends/vulkan/runtime/api/** @@ -113,9 +115,10 @@ jobs: if [[ "${NATIVE_COMPONENT}" == "ggml" ]]; then ./install_requirements.sh --use-pt-pinned-commit - # The LLM runner test imports executorch from src/ without installing the - # wheel, so install its export-time dependencies directly. - pip install flatbuffers ruamel.yaml tabulate \ + # The LLM runner test and the op sample generator import executorch from + # src/ without installing the wheel, so install their export-time + # dependencies directly. + pip install flatbuffers gguf ruamel.yaml tabulate \ "$(grep -E '^transformers\b' requirements-examples.txt)" fi diff --git a/backends/native/test/CMakeLists.txt b/backends/native/test/CMakeLists.txt index 523d450ba50..0874426cc68 100644 --- a/backends/native/test/CMakeLists.txt +++ b/backends/native/test/CMakeLists.txt @@ -58,10 +58,10 @@ add_custom_command( ${CMAKE_COMMAND} -E env MKL_CBWR=COMPATIBLE ONEDNN_MAX_CPU_ISA=AVX2 ATEN_CPU_CAPABILITY=avx2 OMP_NUM_THREADS=1 MKL_NUM_THREADS=1 "PYTHONPATH=${EXECUTORCH_ROOT}/src:${EXECUTORCH_ROOT}/backends/cadence/utils/FACTO" - ${PYTHON_EXECUTABLE} -m executorch.backends.native.test.samples.generate - --out ${_native_op_samples} + FLATC_EXECUTABLE=$ ${PYTHON_EXECUTABLE} -m + executorch.backends.native.test.samples.generate --out ${_native_op_samples} COMMAND ${CMAKE_COMMAND} -E touch ${_native_op_samples}.stamp - DEPENDS ${_native_op_samples_srcs} + DEPENDS flatc ${_native_op_samples_srcs} ${EXECUTORCH_ROOT}/backends/test/suite/operators/test_linear.py VERBATIM ) diff --git a/backends/native/test/runtime/ggml/BUCK b/backends/native/test/runtime/ggml/BUCK index ad23a997116..94105c0a11f 100644 --- a/backends/native/test/runtime/ggml/BUCK +++ b/backends/native/test/runtime/ggml/BUCK @@ -108,8 +108,8 @@ _VARIANTS = [ ] + flags, env = v.get("env", {}) | v.get("sample_env", {}), remote_execution = v.get("remote_execution"), - # TODO: run in CI once they pass. - labels = ci.labels(ci.map(ci.skip_test())), + # TODO: run the others in CI once they pass. + labels = [] if v["suffix"] + name == "_act_quant" else ci.labels(ci.map(ci.skip_test())), test_main = "//executorch/backends/native/test/samples:sample_test_main", deps = [":ggml_engine_host" + v["suffix"]], **v.get("compat", {}) From f67eb1ad942d9cbc741b2701ddf781bcf29df6b1 Mon Sep 17 00:00:00 2001 From: Digant Desai Date: Tue, 3 Nov 2026 06:24:34 -0800 Subject: [PATCH 128/128] [executorch][native] Record dynamic-shape linears as chained bundled samples Pull Request resolved: https://github.com/pytorch/executorch/pull/24405 Engines resize dynamic inputs between inferences, so the op samples now cover a shape changing mid-run. A case built with `chain` is exported with its `Dim`s and recorded as one sample of four calls. Each `Dim` starts mid-range, where export traces it, visits both bounds and returns, every other `Dim` in the opposite order. The harness already runs a sample's calls in order on one executable from one context, resizing inputs between them, so only its header comment changes. The new linear cases are the XNNPACK delegate's dynamic linear tests, each also followed by a ReLU: one and two batch dims, one `Dim` on two dims or two inputs, and sequential and parallel linears, with batch dims down to 1, which tracing mid-range keeps export from specializing. The cases every quantization scheme fits take 256 input features over up to 64 tokens: `dynamic_tokens` in 2-D and, with a static batch of one as in an LLM, in 3-D, two batch dims sharing one `Dim` of up to 16, and the sequential and parallel linears. Authored with Claude Code. Differential Revision: [D128770754](https://our.internmc.facebook.com/intern/diff/D128770754/) **NOTE FOR REVIEWERS**: This PR has internal Meta-specific changes or comments, please review them on [Phabricator](https://our.internmc.facebook.com/intern/diff/D128770754/)! ghstack-source-id: 463657750 --- backends/native/test/samples/cases.py | 32 ++++++- backends/native/test/samples/linear.py | 92 ++++++++++++++++++- backends/native/test/samples/record.py | 33 +++++-- .../native/test/samples/sample_test_main.cpp | 5 +- backends/native/test/samples/test_record.py | 44 ++++++++- 5 files changed, 191 insertions(+), 15 deletions(-) diff --git a/backends/native/test/samples/cases.py b/backends/native/test/samples/cases.py index 82b97316b24..9a613a14db9 100644 --- a/backends/native/test/samples/cases.py +++ b/backends/native/test/samples/cases.py @@ -4,7 +4,8 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. -"""Op cases: a model and the inputs of each sample it is recorded with.""" +"""Op cases: a model and the inputs of each sample it is recorded with, or of +the calls of one sample when its shapes are dynamic.""" import inspect from collections.abc import Callable @@ -13,6 +14,7 @@ from typing import TYPE_CHECKING import torch +from torch.export import Dim if TYPE_CHECKING: from facto.inputgen.specs.model import Spec @@ -30,6 +32,9 @@ class Case: model: torch.nn.Module # Positional float32 inputs of each sample; the count may vary by case. inputs: list[Inputs] + # `{dim: Dim}` of each positional input. A dynamic case's inputs are the + # calls of one sample, which run in order on one executable. + dynamic_shapes: tuple[dict[int, Dim], ...] | None = None def outputs(result) -> list[torch.Tensor]: @@ -46,6 +51,31 @@ def draws(count: int, *shapes: tuple[int, ...]) -> list[Inputs]: ] +def chain(name: str, model: torch.nn.Module, *shapes: tuple[int | Dim, ...]) -> Case: + """A dynamic case with standard-normal inputs of `shapes`, from a fixed seed. + Each Dim starts mid-range, which export traces, then visits both bounds and + returns, every other Dim in the opposite order.""" + dims = list(dict.fromkeys(d for s in shapes for d in s if not isinstance(d, int))) + walks = {} + for k, d in enumerate(dims): + mid = (d.min + d.max + 1) // 2 + walks[d] = (mid, d.min, d.max, mid) if k % 2 == 0 else (mid, d.max, d.min, mid) + generator = torch.Generator().manual_seed(0) + inputs = [ + tuple( + torch.randn( + [walks[d][step] if d in walks else d for d in s], generator=generator + ) + for s in shapes + ) + for step in range(4) + ] + dynamic_shapes = tuple( + {i: d for i, d in enumerate(s) if not isinstance(d, int)} for s in shapes + ) + return Case(name, model, inputs, dynamic_shapes) + + class _SuiteRunner: """Stands in for the backend test suite's runner, keeping what a test lowers.""" diff --git a/backends/native/test/samples/linear.py b/backends/native/test/samples/linear.py index 9beea6408fa..6b6a22b1438 100644 --- a/backends/native/test/samples/linear.py +++ b/backends/native/test/samples/linear.py @@ -4,12 +4,13 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. -"""Linear cases: the backend test suite's, FACTO's, and shapes and inputs that -stress accumulation and quantization.""" +"""Linear cases: the backend test suite's, FACTO's, shapes and inputs that +stress accumulation and quantization, and dynamic shapes.""" import torch from executorch.backends.native.test.samples.cases import ( Case, + chain, draws, facto_cases, suite_cases, @@ -19,6 +20,7 @@ from executorch.extension.llm.export.gguf import QK_K from facto.inputgen.argument.type import ArgType from facto.inputgen.specs.model import ConstraintProducer as cp, InPosArg, OutArg, Spec +from torch.export import Dim def _unit(*constraints) -> list: @@ -90,6 +92,91 @@ def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, ...]: return tuple(m(x) for m in self.linears) +class _Pair(torch.nn.Module): + """Two modules, each on its own input.""" + + def __init__(self, first: torch.nn.Module, second: torch.nn.Module) -> None: + super().__init__() + self.first, self.second = first, second + + def forward(self, x: torch.Tensor, y: torch.Tensor) -> tuple[torch.Tensor, ...]: + return self.first(x), self.second(y) + + +_XNNPACK_SHAPES = ((3, 4, 4), (4, 37, 17), (4, 17, 37)) + + +def _batch_dims(count: int, batch: int) -> tuple[Dim, ...]: + return tuple(Dim(f"batch{i}", min=1, max=batch) for i in range(count)) + + +def _structural( + suffix: str, k: int, hidden: int, n: int, batch: Dim, batch2: Dim +) -> list[Case]: + """Parallel, sequential and both, each linear `k` to `hidden` but the last of + a sequence, `hidden` to `n`.""" + + def sequential() -> torch.nn.Module: + return torch.nn.Sequential( + torch.nn.Linear(k, hidden), torch.nn.Linear(hidden, n) + ) + + return [ + chain( + f"dynamic_parallel{suffix}", + _Pair(torch.nn.Linear(k, hidden), torch.nn.Linear(k, hidden)), + (batch, k), + (batch, k), + ), + chain(f"dynamic_sequential{suffix}", sequential(), (batch, k)), + chain( + f"dynamic_parallel_and_sequential{suffix}", + _Pair(torch.nn.Linear(k, hidden), sequential()), + (batch, k), + (batch2, k), + ), + ] + + +def _dynamic_cases() -> list[Case]: + """The XNNPACK delegate's dynamic linear tests, with batch dims down to 1, + and LLM token shapes every quantization scheme fits.""" + cases = [] + for batch, k, n in _XNNPACK_SHAPES: + for batch_dims in (1, 2): + for bias in (True, False): + name = f"dynamic_{batch_dims}batch_{k}_{n}" + ( + "" if bias else "_no_bias" + ) + shape = (*_batch_dims(batch_dims, batch), k) + cases.append(chain(name, torch.nn.Linear(k, n, bias=bias), shape)) + batch, batch2 = Dim("batch", min=1, max=100), Dim("batch2", min=1, max=100) + cases += [ + chain("dynamic_two_batch", torch.nn.Linear(14, 15), (batch, batch, 14)), + *_structural("", 4, 5, 3, batch, batch2), + ] + tokens = Dim("tokens", min=1, max=64) + cases.append(chain("dynamic_tokens", torch.nn.Linear(256, 64), (tokens, 256))) + for batch, k, n in _XNNPACK_SHAPES: + for batch_dims in (1, 2): + for bias in (True, False): + name = f"dynamic_{batch_dims}batch_{k}_{n}_relu" + ( + "" if bias else "_no_bias" + ) + relu = torch.nn.Sequential( + torch.nn.Linear(k, n, bias=bias), torch.nn.ReLU() + ) + cases.append(chain(name, relu, (*_batch_dims(batch_dims, batch), k))) + rows = Dim("rows", min=1, max=16) + return [ + *cases, + # A static batch of one, as an LLM's prefill and decode. + chain("dynamic_tokens_3d", torch.nn.Linear(256, 64), (1, tokens, 256)), + chain("dynamic_two_batch_256", torch.nn.Linear(256, 64), (rows, rows, 256)), + *_structural("_256", 256, 256, 64, tokens, Dim("tokens2", min=1, max=64)), + ] + + def cases() -> list[Case]: suite = suite_cases(test_linear, samples=4) torch.manual_seed(0) @@ -127,4 +214,5 @@ def cases() -> list[Case]: _facto_case, lambda i, args: i % 5 == 0 or args[1].size(1) % QK_K == 0, ), + *_dynamic_cases(), ] diff --git a/backends/native/test/samples/record.py b/backends/native/test/samples/record.py index 90a57daa399..9465509795e 100644 --- a/backends/native/test/samples/record.py +++ b/backends/native/test/samples/record.py @@ -11,9 +11,10 @@ `precise`, the same math in float64 with quantized weights decoded in float32, whatever the method's dtype, and activations unquantized, floating outputs stored as float32. Weight-only schemes add `golden-int8_act`, the eager outputs -with the same weights and int8 per-token dynamic activations. Nothing in it -depends on an engine or a device: consumers judge an engine by its distance -from `precise` relative to eager's. +with the same weights and int8 per-token dynamic activations. A dynamic case +is exported with its dynamic shapes and recorded as one sample whose calls +change them. Nothing in it depends on an engine or a device: consumers judge an +engine by its distance from `precise` relative to eager's. """ import copy @@ -45,6 +46,13 @@ def _cast(inputs: Inputs, dtype: torch.dtype) -> Inputs: return tuple(t.to(dtype) if t.is_floating_point() else t for t in inputs) +def _samples(calls: list[Call], chained: bool) -> list[Sample]: + """One sample per call, or one running all the calls when they are chained.""" + if chained: + return [Sample(calls)] if calls else [] + return [Sample([call]) for call in calls] + + def record( cases: list[Case], scheme: Scheme, @@ -69,20 +77,27 @@ def record( k: scheme.decode(v).double() for k, v in method.state_dict().items() } inputs = [_cast(x, dtype) for x in case.inputs] - programs[name] = torch.export.export(method, inputs[0]) + chained = case.dynamic_shapes is not None + programs[name] = torch.export.export( + method, + inputs[0], + dynamic_shapes=({"args": case.dynamic_shapes} if chained else None), + ) + golden_calls, precise_calls, act_calls = [], [], [] with torch.no_grad(): for x in inputs: - golden.append(Sample([Call(name, list(x), outputs(method(*x)))])) + golden_calls.append(Call(name, list(x), outputs(method(*x)))) ref = functional_call(method, state, _cast(x, torch.float64)) ref = [ t.float() if t.is_floating_point() else t for t in outputs(ref) ] - precise.append(Sample([Call(name, list(x), ref)])) + precise_calls.append(Call(name, list(x), ref)) if scheme.weight_only: with int8_activations(method): - golden_act.append( - Sample([Call(name, list(x), outputs(method(*x)))]) - ) + act_calls.append(Call(name, list(x), outputs(method(*x)))) + golden += _samples(golden_calls, chained) + precise += _samples(precise_calls, chained) + golden_act += _samples(act_calls, chained) if not programs: logger.info(f"{path.name}: no case fits the scheme, not written") path.unlink(missing_ok=True) diff --git a/backends/native/test/samples/sample_test_main.cpp b/backends/native/test/samples/sample_test_main.cpp index e44d987343c..18e76fde8ec 100644 --- a/backends/native/test/samples/sample_test_main.cpp +++ b/backends/native/test/samples/sample_test_main.cpp @@ -6,8 +6,9 @@ // A gtest main that runs the bundled samples of .ptn packages on the engine the // binary links (see PtnRunner.h): one test per package and method, grouping -// samples by their first call. Each sample runs from a fresh context, and each -// method output is judged over all its samples by OutputCheck. +// samples by their first call. Each sample runs from a fresh context, its calls +// in order on one executable per method, resizing dynamic inputs between them. +// Each method output is judged over all its calls by OutputCheck. // // Usage: // --packages=a.ptn,dir [--rmse_ratio --max_ratio --rtol --sigmas] diff --git a/backends/native/test/samples/test_record.py b/backends/native/test/samples/test_record.py index fd5d28d3b2f..710e89b436c 100644 --- a/backends/native/test/samples/test_record.py +++ b/backends/native/test/samples/test_record.py @@ -12,7 +12,12 @@ import torch import torch.nn.functional as F -from executorch.backends.native.test.samples.cases import Case, draws, suite_cases +from executorch.backends.native.test.samples.cases import ( + Case, + chain, + draws, + suite_cases, +) from executorch.backends.native.test.samples.record import record from executorch.backends.native.test.samples.schemes import ( Gguf, @@ -22,6 +27,7 @@ Torchao, ) from executorch.exir.native import read_samples +from torch.export import Dim from torchao.quantization import IntxWeightOnlyConfig, quantize_ from torchao.quantization.granularity import PerGroup @@ -105,6 +111,42 @@ def test_record_adds_the_8da4w_golden_to_weight_only_packages(self) -> None: got.calls[0].outputs, want.calls[0].outputs, rtol=0, atol=0 ) + def test_chain_visits_each_dims_bounds_starting_mid_range(self) -> None: + a, b = Dim("a", min=1, max=8), Dim("b", min=2, max=4) + case = chain("pair", torch.nn.Identity(), (a, 3), (b, a)) + self.assertEqual( + [[x.shape for x in xs] for xs in case.inputs], + [ + [(5, 3), (3, 5)], + [(1, 3), (4, 1)], + [(8, 3), (2, 8)], + [(5, 3), (3, 5)], + ], + ) + self.assertEqual(case.dynamic_shapes, ({0: a}, {0: b, 1: a})) + + def test_record_chains_a_dynamic_case_into_one_sample(self) -> None: + case = chain("linear", torch.nn.Linear(32, 4), (Dim("batch", min=1, max=6), 32)) + scheme = schemes()["int4wo_g32"] + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "linear.ptn" + record([case], scheme, path, dtypes=["f32"]) + sets = read_samples(str(path)) + self.assertEqual(set(sets), {"golden", "precise", "golden-int8_act"}) + for samples in sets.values(): + (sample,) = samples.samples + self.assertEqual( + [call.inputs[0].shape for call in sample.calls], + [(4, 32), (1, 32), (6, 32), (4, 32)], + ) + model = copy.deepcopy(case.model).eval() + scheme.quantize(model) + with torch.no_grad(): + for call, x in zip( + sets["golden"].samples[0].calls, case.inputs, strict=True + ): + torch.testing.assert_close(call.outputs[0], model(*x), rtol=0, atol=0) + def test_record_keeps_integer_outputs_precise(self) -> None: class Argmax(torch.nn.Module): def forward(self, x: torch.Tensor) -> torch.Tensor: